diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 2c1cdad..0000000 --- a/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules -_scriptsRepo -planning -.git -*.md -.env* diff --git a/.env.local.enc b/.env.local.enc deleted file mode 100644 index 975352d..0000000 --- a/.env.local.enc +++ /dev/null @@ -1,39 +0,0 @@ -#ENC[AES256_GCM,data:49vPC3ejk6KW,iv:/8DJO87b9DmxyGGE3YEdtkuSBgamtfrj78UUwR+rIYg=,tag:oPs5/UBTY2t4i0LXr9Awzg==,type:comment] -NODE_ENV=ENC[AES256_GCM,data:5cCNfFJSNlddkdQ=,iv:bGnAmGCA3G8gDhHY19am0DKWoU6KdSybsNqfT2sfTbE=,tag:0yBDaJ/wE4wpIZoSCPgPDg==,type:str] -PORT=ENC[AES256_GCM,data:5dgzWw==,iv:U4YpZa4CqLgUOjcJWG7kshc4KFz1UfYOPIWfVb6TskM=,tag:vA5nfYr5ilf4GvzR20vC5A==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:AA9Bw7d+JuiXhRUgeglhffwtJrHYfj1VCv8dAiIAAiRcZ5l4H9IseZCdwpkK1HAVSXmQCmQ=,iv:yssOT+JBz5jOtSxmzQw1aIrVHCvsI+/j1dN8041TPcw=,tag:GfLz9+oD23z5gTIWLv0dmw==,type:str] -SESSION_SECRET=ENC[AES256_GCM,data:lr56mYmFH1TtEtqHATUzYLWzmqRPylM3TrpqXBjO4v7gRcobA3p3Asjv47TkvfpChURnCeTrB0c552Ue4TeqtQ==,iv:XdE23plTLUqvFwuSYRgf1XKBcKTRi3iWZrMqUSsWz0g=,tag:6FX+Eh9E8sU4f9Dc27jEHw==,type:str] -#ENC[AES256_GCM,data:IEu179YO951HSIYzjlc6TeStpw==,iv:i/5zqvMv2uLlNNKJqsWnP/iEthC5Ec5Xry4/NboK7Kw=,tag:ypkVagiqkQ8KU3+amaRKgA==,type:comment] -S3_BUCKET=ENC[AES256_GCM,data:Ue3XV054QBAUD+tlNzCF,iv:87tQ2skRacvhPtz27kMn2OKhnEOeD5Xhjnr1hm3c9v0=,tag:nGuWJQscbFzEkD5h0RpeUg==,type:str] -S3_PUBLIC_BUCKET=ENC[AES256_GCM,data:EM8riKv9mrTHQzwxf6IV,iv:7rodyVXj6tDY8q2hqDWO7IHCM2oezNczXKAYd4wtr3A=,tag:e09QO2uhX3DQeZqdbX3jOw==,type:str] -S3_ENDPOINT=ENC[AES256_GCM,data:sU2BhYR9d9f66hkf/W0wQo/00v6KEmZ1rHNSGh+Eb+xTQo4J1ts2hNm0+P+lOqex/XgTMnw1/5Ik992Vx5pfWYU=,iv:RAV3b3nRRj4BQIyZKcCI86mi7etuW4s9ekydKBgMHZA=,tag:gowf9g1eikXZ5vj97rFW0g==,type:str] -S3_ACCESS_KEY=ENC[AES256_GCM,data:TLLepLD+gvgOcfGtmK+3nAe5+2bOSs0nePlb2kumHdA=,iv:0L+LZIeAzzYfKS4kG5KWrAQqgBoC/FLeic3m8lx5kls=,tag:JXk+2nAJT0o5exiyKg6OCw==,type:str] -S3_SECRET_KEY=ENC[AES256_GCM,data:8GR4v7rLL+D30JkbazuxrDq0J+DzU1lutFGwn5VJmsMSSIgnB13QtNSh2yXNHIx6P2vVzUK7+4y3hMX8hCRoVw==,iv:miUA9Cakcs4n9o/Nq8I5msO4bVFBiPVw+abONUNS50U=,tag:5dZMMAkeMTWS3/m8d3bUzA==,type:str] -#ENC[AES256_GCM,data:IKY1FUjvrQ==,iv:o3wmybvpWNy4ck3n308cf65Ymgsl4NZVcjC0BIPl774=,tag:Z5geKTBEDtwTsMgblbxYHw==,type:comment] -BACKUP_S3_PREFIX=ENC[AES256_GCM,data:BRtyCGLVhRD1,iv:vSLjzAwYdwTsrj+dmcZNUCKt4bzrsJAr/y6YgJQFfyM=,tag:o0YrfPie6RmapWoFOEpmzw==,type:str] -CF_ACCOUNT_ID=ENC[AES256_GCM,data:oUwbof7bTJF9R6hXVaepVZwoRcM04jsuifiuTHXYZcA=,iv:zS/HpnIa03NYsAKMNFKTIfmhLwqVctioF8u0lxePbXo=,tag:7RvBUgfKfiQs/ghdiQlwOg==,type:str] -CF_API_TOKEN=ENC[AES256_GCM,data:yyn1DorN2jB1ZzQNooVFuMbAmlgpAeHVOD5CileKoJfGe5J76bGaxNcJJFLoJYHvztRiLV0=,iv:d1UYqCoiN0ficU848qNSHuCtCc+zg5IQcJg33Miq5GE=,tag:YVrJDhPoEDvD5Em5b7yobA==,type:str] -#ENC[AES256_GCM,data:HazdGoFon9cRJlRpBhNshZxXb/o=,iv:lnrde4ZwuoU9TALA7sqZ4PG8fF7nwmfuy5IGR73YkaM=,tag:Qis8L+tI68YB+bzwVxwVAg==,type:comment] -OIDC_ISSUER_URL=ENC[AES256_GCM,data:y7BOYQeMzgc3hrpQaRR2SbrUMBHY,iv:mqxpxl7uxhENYdtAq9oEZweF8hVXwHOe4CIPaxGrjEA=,tag:Fl6fUouzdPsa+A7P/mNbww==,type:str] -OIDC_ISSUER_INTERNAL_URL=ENC[AES256_GCM,data:ewz0UA8h+Qm8ZX/fm6zRQ35L8M+a4vxbsn21Qm/hxHY=,iv:2yaS8I97rNO1IzBY1TbVew0k66/2IaxC77OojbzRGPo=,tag:e9IRiOAZTkafwzYGnU9F/w==,type:str] -OIDC_CLIENT_ID=ENC[AES256_GCM,data:e1JtQRqun/OB5qo=,iv:u8ngNpfwPuoBUmEP7LDD1X37Zefceesh1sZhvDHGjyk=,tag:4MxrWpUNtXWC27DYJf/p5A==,type:str] -OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:Ndmt6Z7Pyb+lJP32wCGxsB/xhLNr992btFZCNC3NmOk6GgjiRozN0ToMUAN2kyMwWarpvF3qytHZgmjUmWxECA==,iv:PSbDpOTRjCQ6JZhLog0twdZkQFPzS3hUYZlsO0AoqRU=,tag:sE2KiiWrBMJqgGpJUK9llw==,type:str] -AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:rb5uBSRQm+36OyDhtxeCRk1k+MZ8oDmLzWjtJ2h8Q5I=,iv:LsAlcjQL6bo6WPnOmXU79dj13dW+KUDHi1r9nz76yoo=,tag:/psnnv5wtV3uSOd9rtDxdw==,type:str] -OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:/Ql8BIA4N7LlesbiFvAS0XARhwZV,iv:XXA0C6gZ9l17StxsU0VSmGoi7d06T9K7PW88EJp/tnA=,tag:2+fWnnAHhi9vvRMHFCgKPQ==,type:str] -APP_URL=ENC[AES256_GCM,data:W8YCjhg2qwH6iR0Gao66tK9wXZp+,iv:6teKauGRn8tSB4yGF3dmQj0wwf/8h2gI6XPQY69z83c=,tag:BJg0GDPP3NXVixLnzTKYhQ==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0K25jb0h1VlBCZnV6eWJk\nSWpSbWFhVjF5M0xHTm1FODdualhiMDB2OFZRCmhseUR2ZkxxL0dkdUtUeXBTVE02\naWFqY3pKblVFNGN6S09GcWFnSnZxa2MKLS0tIFpISjlqdFN4R2EwWitOMHI0ZnVw\nZS9MSEZKL2Fvd2xKTlQza3hWMFYwYzQKyYv3gzjWQLjvh83DSWecC6X7YIYSJa/B\nkyuw6KhRAwPJNtRkDML6SWh6JMnG15cJO9uP65gi+PO6XjWmWbUvvg==\n-----END AGE ENCRYPTED FILE-----\n -sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrYjdqMUlyZkVGL1dnTWEy\nSEtZZVFRRjlnc2F6UGlKMERKTHdOVTI0Z2xnCmpRQnR1MjZvS0s0WkppQkpyV3dj\nbkJGUmYxU1NBRjVrUjFCclNnS3IwbVUKLS0tIGxqd0wvRVEvQ1JlcjJOWDlZd3RN\nUkFJdFFOTmVOcnBEcUFXcGNDT2NBN1EK15cvctDmsGLnUPElLqNt5t4Fd/ygBzJh\niCazrSUpIA8RDiBxUFn3qc//dXhLVACyasPaD/cAsMI1YnKCBLIbjg==\n-----END AGE ENCRYPTED FILE-----\n -sops_age__list_1__map_recipient=age1ysddqggsx3h8zkv7xn3z26sjak5pqms6pyqhnky9ukrvpk7es5jsayz8w7 -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzaGIvR0lQTUhGejdDUDdO\nazJpRlZ6Z2IvWXFTa3I5TVNDVm0zSkNTVmlNCmFGUk5DWFVqYVpEMkpBWEFScnVa\ncXR2YUJJZE5xa1A0RU1pYVRGT1lNVlEKLS0tIFdYRkRUVnAzSXVERko5bHVJVFVN\nMkFGbElWeXlXQWxpRTlUcEY3SFd2ZDQKUnfXknBPhlVYJgS30nge4kag3yJniuSB\nWUO4HvMDxkj8mXKsKGed2ny8cYkCZsqXOdxaWWGtk5GsUe/v9dUw/g==\n-----END AGE ENCRYPTED FILE-----\n -sops_age__list_2__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLQm9VZENUZVg1bkpHQ200\nVjUvOWg3N0M0aTBFSTdrdG9EMjJwTTFvank4Ci9qaHB6OVphYksxR04xTWJiekJM\nYWRNWkpnTEpmR0NzczRHVk0wR1dkMDgKLS0tIFlZVFhuZ2U0MUlWOTYvbndFeHZF\nWTM1RUFoNzdaVmlXck9YWjhrV0tybjAKzVao/TJ3ZzAtxNfptKP0myEKRE9Tf8r5\n2HtfWp7su0l6/kHrUdlKf9PKe8k9ebuxEvSjreB/tj+BdwMZkWrg4w==\n-----END AGE ENCRYPTED FILE-----\n -sops_age__list_3__map_recipient=age1qn0x93jhqjpqwvx5tgxnrwq5e3vuzur9whrkdnrvapd58esm45rqfkuxqh -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSZWRxM0lnekxWcEFJRGM4\nT2lkWlpTcVNJa1ZHRVJsbHl4QjVjWWxXQW1VCnNGZ3I2YUwxMnlYMXAzeEw3K1FN\namREblh2Mjc4SkIwTFRZVGsvSEhmV1EKLS0tIGN0UTdwdy9xR0I5MDFPa1dPeFoz\nYkhLeUkzQjJYNDNnTzdKNFpjM09LQzgKKRE/yB21pgXUe+kv0pw7UfjEs/RHDsAb\nhsPcj09HpAhauFzQxkIqFyOagshZ7c/OhAbfPwmZ2ycm5mLW8WOSRQ==\n-----END AGE ENCRYPTED FILE-----\n -sops_age__list_4__map_recipient=age1h86dek80u5t677tsparz395uk3zvz4yuj9m5t2v2nsdfsvyjmafsra5yt7 -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOdktDT21TWXBWdmZaNEdn\nOVFiNFQrTmIxVlBFMlJIVlZqRHo3NC9FSENnCjBqQWI3alpwbFFHYmJkZW1JaWoz\ndXdGSFo4R0RoK0g5Q2Z0QjNrYXBsVmMKLS0tIHdxNEJHTFZ5dk8zYUJRQWJVWFRJ\nWHllTnQ0MHhmdzZLNFVCMnRMS2Q4OG8KSR+VznPpiTPuM1PCgQqnHyJcgxwf69We\nrmcyR3jNbIgOv/re1ZhvtfQg2vJhF4fz7QvTyQWFZbIniFgCLKEcJw==\n-----END AGE ENCRYPTED FILE-----\n -sops_age__list_5__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_lastmodified=2026-08-06T18:36:16Z -sops_mac=ENC[AES256_GCM,data:AaWybTpzzvC1IMHF7eM/kczSMCY+O66Tj0gxuSOQRho93zcrEl0CCNfsq7Ptk/g8Bl+TIVZywgAIiIHKXzKKliQQaKcn2ONtFI2/tFU9HCCjbod3EtD6rP5Uw5BtOhaozUVyQlYNmr/AquZb3KoWsE6Ci0NRmJ7ldh5pSiEfNbA=,iv:sZbxN6kiHyPaqi2SlzZqZewEYuEd8egCSFtuQO2RP/0=,tag:e8saJo/8cdvk5bdlTFLg9Q==,type:str] -sops_unencrypted_suffix=_unencrypted -sops_version=3.11.0 diff --git a/.env.prod-v2.enc b/.env.prod-v2.enc new file mode 100644 index 0000000..16a6600 --- /dev/null +++ b/.env.prod-v2.enc @@ -0,0 +1,25 @@ +#ENC[AES256_GCM,data:uyDJVNkWT0y9PTSPX15CsqPxpqCLEutWb4qjKfhtC0BN17PKeVnXeH7A+1eaAQIPjwc8Q8PhGFayFb9VGWMqAh7p9lOeabLL2NfKG8xg432tCyyAazDZFn7hpT/GNAy20SUw/nJiah+i0w+X,iv:254kJ0MBxAkYsasc+asoK5mGMofzqqw2xahqXjIuP+w=,tag:7fUACA6CPX0mbGMlREOeUA==,type:comment] +SIGNING_KEY=ENC[AES256_GCM,data:A4PAGMONiXri0uQNshY+ur2H1/DSTTkmNcEOh8EVA64PPjhqP7Kt0yuG0g==,iv:Zr0zSimpuW/+c66YO70oHgbpobdiqpbktbb4TGwfiJU=,tag:+tptSTSTlcGiHJIFf0+JcA==,type:str] +LOCATION_KEY=ENC[AES256_GCM,data:iATgADyTGtQPQznCKg65km+7r0Kw8Pg7vDFp3Bbk3CWA57GZ4M9+4N1ZeQ==,iv:906m8zdHUf5R9VGZsW8HbDaZR4lxS9Cq/1tVr+fuGhM=,tag:SBJCCdodedt2CM/AI7VnQg==,type:str] +SESSION_SECRET=ENC[AES256_GCM,data:tuILJNlEID8jYVMgQZJF8nrq4RDUfBfD1czDQu/uKVJclq+SrGwK2DYuZg==,iv:sHxO1eu0MvB1rcf1MhpoeX7r1D7jCiW82nmxNP3ovAE=,tag:wFhn34oC7wHlUtnHSfcq8w==,type:str] +OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:pnIuM3zHuGaUK2eOMYiX5stV5rrd+sICNWxplZYizaLWhiMQ08LR4ftv4SbxVvBVdCb+dwsQZsfQ/tG8NJfUPg==,iv:P6xz2mR1epra8PnO0Lm88GXQsF7lxSsHOP+daa+qwvo=,tag:xghe0n8nqAM4Fjo5Nbz32w==,type:str] +R2_API_TOKEN=ENC[AES256_GCM,data:o1pmULq8mwcvSTOBWLkBVe7zKWV2CewsP85zexG3CVV1znCkHjhgSW6guqmNWapf8GtnJAg=,iv:DqhfFRBQaF6o4Sz7yRzdzbONEv/IIWPNYKrFYrbh+SQ=,tag:oep7noE6DjCoGkKuQYARvQ==,type:str] +R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:3ZtWCOe9S3MPjs4cEvRwV732FmuEH5izGGgS+ImBXLg=,iv:CqSvo/Mj76NV6XEB7iqocxYD4Af2zlRJVvaN+FAyO3A=,tag:sjuYfiqngGGFh++GQbRS2Q==,type:str] +R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:PXkrTwKFyFRQgPzJ7QFPThsPqcTmsrYwJMdwQBmDPbyW2Jjf+u8m4xYe7KFT6t0iEM4AAvWBf+DzoYsRcyRPeg==,iv:cl/LUndMCodY904gmyIr0ohoJPmGH3yZHfxj+fFYm2A=,tag:t9u+7vEzWmlmSEtaHNpX7Q==,type:str] +AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:gLtDfJwLbEzzq/ojnn+c9ClXl6AP5oNie+XnMNfWijfzCVXbaVNxDZu35NHhkHVp2T6gRubR+jbd2b0AQC76ow==,iv:joRi9MGLWXbrkNEYpOh2x8ztsmcTxs/9+jw4R8JpFEo=,tag:k29GhDHkc6rp4P3mcpJuvg==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCSGpETTJkaFFBRTlWWGdE\nMlhGVU5SaUh4L3hQa3dDYWZleHFwTTZRdjJvClhMOCtVam5CMXJKT3BYNEpGQzVn\nUVFVZmNNbXJOODNqUXdCdzhGUHFQVmcKLS0tIDVvdDZ0MlUzaW9SNWJzcnZ5d1NX\nR1Awdk1TeGNPL0JsZjRIUk1lblBHblkKtBQfM03Ny/oKza4+p77SPE+B9H2HeHbI\njIcOsOXg3K7ZrN8Zqm146kU5A+zhPuJEuRy64g8xC35LYH+dUwbLtw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQZXRwY3lHM2F5bCtDMFpR\ndGpDdUpCWVNwb0NWUHgwdGNSbHp4ZzgwK1c0ClBVTzJka3JzK2tsM0F0MkpObTJF\nelhTdWhLZG1tV3RRUGgxT2VNNnNEaDgKLS0tIDArM1ZDdCttSmNyVWlKdk9qTDZF\nNTF3NzdVaXBDZit4RDlXNnhUcHdMaEkKZUK3FHDGsoKJHDai75Uz2sP9ZkvB3WBU\nupiRVOcBx6qcI+8A6LMehtx3Ei41NXN0Cs/WOv7ClzbQzgy27sK+Ug==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_recipient=age1ysddqggsx3h8zkv7xn3z26sjak5pqms6pyqhnky9ukrvpk7es5jsayz8w7 +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHczBES0ErM0RCbmxSTFkv\nZWdvcDJqVlphSWxjaEI1cHhNUjI3ak1sK1FBCi84azdkYWZRcVR3ZEViR1B2cWdj\nMDBCeWhtN21wVXU2dURtaFAxS21rY0EKLS0tIHU5MzJGTTQ2RjA4WnBCSHJNZjk2\naWRONHBaUWJ2bk9sWnljQ01aYjdiUlUKIbhUc1duZ/Q38RZw3Ct1MLHz73HWjUx4\npCZXlpQc4ajzqzwd71FLhOuIAfRuD/kxAgOCI1rrBAuPcjJ9GWUlPQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1b1E1bEtvWXZHOCtneGl2\nUHpaRGVkTUYzR05lL1g4enRZdHI3VS9PR3dFCmV2UzhwRURTMnhwb29ZdTQrMmpv\nYnREYStvZ1pVQlQ4UFp5eThGSVhMZTgKLS0tIGc0UjV3REQzWWtJYUhIUm5TWW5R\nZGNtOUk0amdLbkcySFUvTXFMWGE1YXMKM7/x6cmsD8v3GGIeer/OnIuix9OoJTzO\n4Dn8LQhpwhiQzMGOcpb6wKH3hlVtzvaScNnP684ExgeqJVDsFPeuPg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_recipient=age1qn0x93jhqjpqwvx5tgxnrwq5e3vuzur9whrkdnrvapd58esm45rqfkuxqh +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlL0xhcWd5aGVpYmlSb095\nOGUzRStvdG5seURQMkg2L2l2Wi85dUNqb2tnCmJSTkRIRHQvZnB4c2YyTGFuOU5u\nWHBBU1VUZlpkRzRteXhNaWZuSE40aG8KLS0tIE8xdjN6cHp3N1BxM0hGVEwwVnhw\nakFmM281ZFRQT2I2dm1Zc1F3b29wWlEK6wp6sV/YqcQOvBDYw4/rGSXr+0qsNm06\nBVEKknx/zYMiYDKeQcIsLu9yIEHlURJ0atfop48BUkVsR6PbgmpgNQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_recipient=age1h86dek80u5t677tsparz395uk3zvz4yuj9m5t2v2nsdfsvyjmafsra5yt7 +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNdHpWaU0ycGgwaUcxM0JK\nWmZxWlRvZnlPOEI2c3pJMyszK1gyWENZb0ZNCnpzTUNIZGpsZ2VpUWd5V2tPQnF2\nYjZnVG84Y3B2R01uYWFFbXdsTjYxdlEKLS0tIHBlYmFpd29xT3NLV1VjUCtUVmhR\nVW1YSDg0ZzBtQTd0R0dUQ29LZVdZR3MKeSP91xyZHIt6QXr/Lz7ZhWCn0JvX8gfX\nJg9cDsHdmsVRFdn2ipWP5Acx2qp/C5MosWENB3ITrgV9TEkJ7E9Jcw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h +sops_lastmodified=2026-10-05T18:44:52Z +sops_mac=ENC[AES256_GCM,data:Cq6n0j7gJ5itoCtqhh4tRgvquH0TcgddlxgEB70wKghvNS8WPmoA55OSvOwTLbwvdGYcxjuIy/VukdJlSqZ2gu3wwaxbnlaJgSGBecIeWlgXk+1BnQu3Au+laECE+spEMSlmxh3rTBKtw26baIGivV6ojfUxOyDeByBtikt2lC4=,iv:zUbxFgvlqYHCRm2Ihp/DnBv5Fm5wV+6n1VSA8YG82tI=,tag:/bU46QR0pMaBKbCAwxuWYQ==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.11.0 diff --git a/.env.staging.enc b/.env.staging.enc new file mode 100644 index 0000000..7737a68 --- /dev/null +++ b/.env.staging.enc @@ -0,0 +1,25 @@ +#ENC[AES256_GCM,data:L18HvVhVLrA+ki80CVjZV9xs+DsOsEvszm+F9g2yTGZttt84xfWMIqJ3Zlo3b+3A4F/jEhZ8KPLfBqfp7ylI5QsOmfk8PR4zvrtFMHYE/AZB+Keiuin/tK+rag==,iv:x3yKH8w3vyOFcMVhvZ8/MTn0sa27vStt+x67t/YzN6E=,tag:Balm2ehHO8woo2OFrwDvHg==,type:comment] +SIGNING_KEY=ENC[AES256_GCM,data:Uf9wdQf+EhbqfbufFyyYvwyn2XbKVneF3tU23OTEmfV4e/jvnsi9a0GVEA==,iv:wvaJeETb8ryvRLRQxPILmO23JcFEgPrbVqlZt+TxB1k=,tag:dHC2GV+JhSIKxV9VetbO9A==,type:str] +LOCATION_KEY=ENC[AES256_GCM,data:srIB/K3yBVjTRd/2VivT6qaaGg83Fxa4VCd+Zk7H2O1L7xxrV1qLX1ofXQ==,iv:GqYlEr0SVb+X+EFL6Q+wszRhcUkakon3Z0TA95PKbNE=,tag:jrk9EZkixCmI8sHzSaDaxg==,type:str] +SESSION_SECRET=ENC[AES256_GCM,data:M2/TE/R4+NDKyeWBCYN0lzkadg+2MaESsOPh9EoRQYxBGAhZHP4Lt3o+OQ==,iv:ClvpJl/soXCA8fPRmPo+io8b167q7WiODLlpAN27jfY=,tag:Rjhe41njbZ6LNIEQ7J143w==,type:str] +OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:qKP3W3LSShbJ6pQK0VjNWYYIab6OLha6kZoJM8sUkWXf2JHOL4ZWAtisDsVIkxjzcJWJJ55ZC/bK4sRlVDq8rQ==,iv:AWXZHqNR+jUL3+U7bLepAR6KbD9mwnNXAKuV2GiwIhU=,tag:UUhOqRg3WBhH5m7jenMYkw==,type:str] +R2_API_TOKEN=ENC[AES256_GCM,data:6xecHO7T6D7npUiqIIHo9CectjDBNwm4VlS/ZGHNwLNqxIMyoHbN5XeKVgo7Taa4B701MNE=,iv:+iNkvek2zJNloaMPZpWMkryREm0naFnQHokbqhFjemA=,tag:i4S8brDoEiBa3gKq7ZqHZA==,type:str] +R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:ups38t8iyZuDi0k4X76sOh0Njd0F2SJn6ozIiTJOv3g=,iv:jxBqZBUPz81BRYCC34ZWR2heOh8N/ca0biFQab4sX/M=,tag:VlGZQ3M61bLKLhkS6isaYw==,type:str] +R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:deaqHLFsI1dMX+daJCMhQ5FH6GkKc6Jec+ooo3tZz8nlc3HtxMYooHP0g8GbkIX7q2VBjQeOLwUraZzYWdBs2w==,iv:u/5e3cjkdeQyj7QWp7J2WBvUKE8klRYgBfxNmoQpRrs=,tag:cl3ztXz1aJjPTEPIyK7CLQ==,type:str] +AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:tUYoRLvtDCseoEJsVfaWOWPJqS37/2Mefvme7vbIOHEhPI6nTEimCmDZbV/SsYv/1SuSIW34toqcxH1Fnl7MIg==,iv:Urp+ZTkZLvTpybFAt4MMH6o9CQGICZjSIzjrb+AA1S4=,tag:I9lCrmiIuwBwIZww26/OfQ==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaWFJTTEZFRElBckZsSEE3\na1pGQzRac2FGOE1ZSDBmZDBodGZjRjFYNjNjCk9RNVVoWVNCNU5JV2FvVnM2NzJW\nSmhPU1dCN0REdFQrUjFCaGppVlAxS3cKLS0tIGt6OGdNNFhxWGRZT0pNbHY5QWtp\nOWJMUzliSkVqZE8rTStGWFF2eUpNRDAKJMxcuM9S+RNJA6M8cOoDKu21Cd/VLm5A\niHHFWPVgcaHSHVTQnzwbo0zHkE2ue/xhdctm1QD9nRe+AtFm0ogRzA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXek54eDFkVnBCNXkrdjMv\nalptTUVUTXAydDBJdjMwcWlKT2Izdm9GckNNCmw2L1o0dmZyWWVHTjhNd3JmVXM1\nVlVDSHk3ZXgzSmt5S0hYTytuaVEyaEkKLS0tIHpiQmU5K1BjSklYMHVLaW1yM2V6\neW4zeFJFeFEyYXBIMkZHSXFPc1JDUkEKXXzkYVfW0z5dhpRIfQYcKnNY3Z46+HDl\nSIL8E6qzYGB+Wmny0OHkhdy9/yGErCxrwJWH5RGjFt6NzjSvMwOUiw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_recipient=age1ysddqggsx3h8zkv7xn3z26sjak5pqms6pyqhnky9ukrvpk7es5jsayz8w7 +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQZzRiL3YwU2pybmFVL1k3\nczJ4L3ZkQ0ZsOGlWVlhqbWRMWVN2RUNnTlZzCjJldkdFTnBTcHA1YkkxREpac2xO\nMUVwNkxGVWNydlRFNEN5cGFQck5na28KLS0tIHZEaHhNSTIxcnh2TFkxcjRDczZl\nOUY3cjE1MFBLbFhlQ3hqVXZyWmQ3K2sKyLWvRb79PjFk683XW2SPRbHMX7eZerAi\n1hhjnE8n1V+EJvu6coB+/IG+WQPgT/t8JY/332RQswn/rw+AMwGx9w==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTRTZDa2ZvaDcxZ2tpUTJj\nOGUrbkdkdjNJZmJQcVRkK21OTnUwbUl0d0VrCmNzQ2VOQjk4SDR1UWRrVDRHb3pa\nK0JBeHN5akd2QXhBOW9jVG5rRVRHNDgKLS0tIEFkSlRwNjZzZTdWd01mcnVQYU5h\nK0owb3V4VFZOeldmdVBndUo2NTdlQ28K3v4iS1YCiFPRsSo5fyw99l003KokQqpN\ntEMwdbtD61YPRRgFcrMmN71SkyjoNLUrX/5atBHnEaDkivRw3tCeqg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_recipient=age1qn0x93jhqjpqwvx5tgxnrwq5e3vuzur9whrkdnrvapd58esm45rqfkuxqh +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1aHJMR01jbnBpdkU3TTdn\nV2Z1dk1aRW5oY1VQdC9KUEQ3NDN6alF0aTBJCnhBcEc0dlJGTWRKZ0dDQ3loWUwz\nUDJiZG1tZzltN0wvbUUyRFpYbDJKM3MKLS0tIDZMK1BySllJeGkxNTRscysrdHJE\nRHVIc1RvMHJFc1dRd3F2SUh1Z1hKZUkKA6vg2GmwBBmGk7UHcb1sbhZIf42Ds3k+\nmg0YaSE7k4iBqm44Lh3arFrmfaosCyPeYB1wyqXlxR+k0nvvijpzvQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_recipient=age1h86dek80u5t677tsparz395uk3zvz4yuj9m5t2v2nsdfsvyjmafsra5yt7 +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMYzN2cTFMM1hDd3hyYmFp\nOWpoTEM2K3UwNnhjeEVScEVCTmlDSEJlMDBvCmsrZUEzYVhSTHBmSGZ0Mzg0QWtW\nb1YxM3N3VllYZ1VMcEJpc05KZG4xcWcKLS0tIElDT1ZGOWJxRVorNjRpRGZ5akJY\nTlloR0d1QnBMV0pxckRUVzc2bHJVa28KZYl/NOcr3pLFAn+4yaQDejqKJr5A2e0X\neDQuVacIicznbRcryp+IpGfdW8+H+BgXif6insXqjCz0lX2q/xopnw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h +sops_lastmodified=2026-10-04T16:46:10Z +sops_mac=ENC[AES256_GCM,data:v/OHKSnYStOf//dP8FAnRNfUsn5ixX9GjKBNfJP6ByL9av5MyMUS7Pd7q8lDv/RhQHFn3v+KISOk7f9Ezp5/vLW+7+Rip/FRbViMuzEqwRjCkKqZNO79hzPIzYT+mvknirtDk6BO3NqaYAan/1vnRCwTq43TTSsMoW0swCUirZI=,iv:sizhCYXhA1u0TBlg2joo2W/CpJCXw0SdGqyYycDA4KE=,tag:oGulAwmz5BCr5Zfjbx2vdQ==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.11.0 diff --git a/.env.test b/.env.test deleted file mode 100644 index c93772b..0000000 --- a/.env.test +++ /dev/null @@ -1,14 +0,0 @@ -# Underlay -NODE_ENV=development -DATABASE_URL=postgresql://underlay:underlay@localhost:5432/underlay -SESSION_SECRET=dev-secret-change-me - -# ARK -ARK_DEFAULT_NAAN=12345 - -# S3 (MinIO in dev) -S3_BUCKET=underlay -S3_REGION=us-east-1 -S3_ENDPOINT=http://localhost:9000 -S3_ACCESS_KEY=minioadmin -S3_SECRET_KEY=minioadmin diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fef46aa..43eb47f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,9 @@ run-name: >- on: push: - branches: [main] + branches: [main, v2-edge] pull_request: - branches: [main] + branches: [main, v2-edge] concurrency: group: ci-${{ github.ref }} @@ -21,7 +21,7 @@ jobs: check: name: Lint, typecheck, test runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout @@ -52,3 +52,12 @@ jobs: - name: Test run: pnpm test + + - name: Browser bundle + run: pnpm --filter @underlay/protocol check-browser + + - name: CLI build + run: pnpm --filter @underlay/cli build && node packages/cli/dist/cli.js --help + + - name: Web build and SSR smoke + run: pnpm --filter @underlay/web build && pnpm --filter @underlay/web smoke diff --git a/.github/workflows/deploy-mirror.yml b/.github/workflows/deploy-mirror.yml deleted file mode 100644 index b65ae20..0000000 --- a/.github/workflows/deploy-mirror.yml +++ /dev/null @@ -1,177 +0,0 @@ -name: Deploy Mirror - -run-name: 'Deploy mirror: ${{ github.sha }}' - -concurrency: - group: deploy-mirror - cancel-in-progress: true - -on: - workflow_dispatch: - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -permissions: - contents: read - packages: write - -jobs: - deploy: - name: Deploy Mirror - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Install pnpm - run: corepack enable && corepack prepare pnpm@latest --activate - - - name: Install and typecheck - run: | - pnpm install --frozen-lockfile - pnpm typecheck - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - target: production - push: true - provenance: false - sbom: false - cache-from: type=gha - cache-to: type=gha,mode=max - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:mirror-${{ github.sha }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:mirror-latest - labels: ${{ steps.meta.outputs.labels }} - - - name: Start SSH agent - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - - name: Install sops - run: | - curl -LO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 - sudo mv sops-v3.9.4.linux.amd64 /usr/local/bin/sops - sudo chmod +x /usr/local/bin/sops - - - name: Extract DEPLOY_HOST from env file - id: host - env: - SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_SECRET_KEY }} - run: | - set -euo pipefail - DEPLOY_HOST=$(sops -d --input-type dotenv --output-type dotenv .env.dev.enc | grep '^DEPLOY_HOST=' | cut -d= -f2) - if [[ -z "$DEPLOY_HOST" ]]; then - echo "::error::DEPLOY_HOST not found in .env.dev.enc" - exit 1 - fi - echo "deploy_host=${DEPLOY_HOST}" >> $GITHUB_OUTPUT - - - name: Add known hosts - run: | - mkdir -p ~/.ssh - ssh-keyscan -H "${{ steps.host.outputs.deploy_host }}" >> ~/.ssh/known_hosts 2>/dev/null - - - name: Deploy over SSH - env: - SSH_USER: ${{ secrets.SSH_USER }} - DEPLOY_HOST: ${{ steps.host.outputs.deploy_host }} - REPO: ${{ github.repository }} - BRANCH: ${{ github.ref_name }} - GHCR_USER: ${{ secrets.GHCR_USER }} - GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} - IMAGE_TAG: mirror-${{ github.sha }} - run: | - ssh "${SSH_USER}@${DEPLOY_HOST}" \ - "env GHCR_USER='${GHCR_USER}' GHCR_TOKEN='${GHCR_TOKEN}' IMAGE_TAG='${IMAGE_TAG}' bash -s -- '${REPO}' '${BRANCH}'" <<'EOS' - set -euo pipefail - - REPO="${1:?missing repo}" - BRANCH="${2:-main}" - - : "${IMAGE_TAG:?missing IMAGE_TAG}" - : "${GHCR_USER:?missing GHCR_USER}" - : "${GHCR_TOKEN:?missing GHCR_TOKEN}" - - STACK_NAME="underlay-mirror" - REPO_NAME="${REPO##*/}" - APP_DIR="/srv/${REPO_NAME}-mirror" - REPO_SSH="git@github.com:${REPO}.git" - - ssh-keyscan -H github.com >> ~/.ssh/known_hosts 2>/dev/null - chmod 600 ~/.ssh/known_hosts - - if [[ ! -d "${APP_DIR}/.git" ]]; then - sudo mkdir -p "${APP_DIR}" - sudo chown -R "$USER:$USER" "${APP_DIR}" - git clone --branch "${BRANCH}" "${REPO_SSH}" "${APP_DIR}" - fi - - cd "${APP_DIR}" - git fetch --prune --tags origin - git checkout "${BRANCH}" - git pull origin "${BRANCH}" - - # Decrypt prod env to source S3 creds and API keys - umask 077 - sops -d --input-type dotenv --output-type dotenv .env.prod.enc > .env - set -a - source <(grep -v '^#' .env | grep -v '^$') - set +a - - # Init swarm if not already active - if ! sudo docker info --format '{{.Swarm.LocalNodeState}}' | grep -qx active; then - sudo docker swarm init - fi - - echo "$GHCR_TOKEN" | sudo docker login ghcr.io -u "$GHCR_USER" --password-stdin - - sudo docker pull "ghcr.io/${REPO}:${IMAGE_TAG}" - - # Deploy using docker-compose.yml with mirror config. - # S3 creds and API key are sourced from prod .env above. - sudo env \ - IMAGE="ghcr.io/${REPO}" IMAGE_TAG="$IMAGE_TAG" \ - PORT=3002 APP_REPLICAS=1 \ - UNDERLAY_MODE=mirror \ - UNDERLAY_UPSTREAM="${UNDERLAY_UPSTREAM:-https://www.underlay.org}" \ - UNDERLAY_NODE_NAME="${UNDERLAY_NODE_NAME:-}" \ - S3_ENDPOINT="${S3_ENDPOINT:-}" S3_REGION="${S3_REGION:-auto}" \ - S3_ACCESS_KEY="${S3_ACCESS_KEY:-}" S3_SECRET_KEY="${S3_SECRET_KEY:-}" \ - S3_BUCKET="${S3_BUCKET:-underlay}" \ - S3_PUBLIC_BUCKET="${S3_PUBLIC_BUCKET:-underlaypublic}" \ - UNDERLAY_UPSTREAM_API_KEY="${UNDERLAY_UPSTREAM_API_KEY:-}" \ - SESSION_SECRET="$(openssl rand -hex 32)" \ - docker stack deploy -c docker-compose.yml \ - --with-registry-auth --resolve-image always --prune "${STACK_NAME}" - - sudo docker stack services "${STACK_NAME}" - echo "Mirror deployed as ${STACK_NAME} (image: ${IMAGE_TAG})" - EOS diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index a071d74..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,239 +0,0 @@ -name: Deploy - -run-name: >- - ${{ - github.event_name == 'workflow_run' && format('Deploy dev: {0}', github.event.workflow_run.head_commit.message) || - github.event_name == 'release' && format('Deploy prod: {0}', github.event.release.tag_name) || - format('Deploy: {0}', github.sha) - }} - -concurrency: - group: >- - deploy-${{ - github.event_name == 'release' && format('prod-{0}', github.event.release.tag_name) || - github.event_name == 'workflow_run' && format('ci-{0}', github.event.workflow_run.head_branch) || - github.ref - }} - cancel-in-progress: true - -on: - workflow_run: - workflows: [CI] - types: [completed] - branches: [main] - release: - types: [published] - workflow_dispatch: - inputs: - no_cache: - description: Build without Docker cache - required: false - default: false - type: boolean - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -permissions: - contents: read - packages: write - -jobs: - deploy: - name: Deploy - runs-on: ubuntu-latest - if: >- - github.event_name == 'workflow_dispatch' || - github.event_name == 'release' || - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.event.workflow_run.head_sha || github.sha }} - - - name: Set deployment vars - id: vars - run: | - if [[ "${{ github.event_name }}" == "release" ]]; then - echo "image_tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT - echo "env_file=.env.prod.enc" >> $GITHUB_OUTPUT - echo "stack_name=underlay-prod" >> $GITHUB_OUTPUT - else - echo "image_tag=${{ github.sha }}" >> $GITHUB_OUTPUT - echo "env_file=.env.dev.enc" >> $GITHUB_OUTPUT - echo "stack_name=underlay-dev" >> $GITHUB_OUTPUT - fi - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - target: production - push: true - provenance: false - sbom: false - no-cache: ${{ inputs.no_cache || false }} - cache-from: type=gha - cache-to: type=gha,mode=max - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.image_tag }} - ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - - name: Start SSH agent - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - - name: Install sops - run: | - curl -LO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 - sudo mv sops-v3.9.4.linux.amd64 /usr/local/bin/sops - sudo chmod +x /usr/local/bin/sops - - - name: Extract DEPLOY_HOST from env file - id: host - env: - SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_SECRET_KEY }} - run: | - set -euo pipefail - DEPLOY_HOST=$(sops -d --input-type dotenv --output-type dotenv "${{ steps.vars.outputs.env_file }}" | grep '^DEPLOY_HOST=' | cut -d= -f2) - if [[ -z "$DEPLOY_HOST" ]]; then - echo "::error::DEPLOY_HOST not found in ${{ steps.vars.outputs.env_file }}" - exit 1 - fi - echo "deploy_host=${DEPLOY_HOST}" >> $GITHUB_OUTPUT - - - name: Add known hosts - run: | - mkdir -p ~/.ssh - ssh-keyscan -H "${{ steps.host.outputs.deploy_host }}" >> ~/.ssh/known_hosts 2>/dev/null - - - name: Deploy over SSH - env: - SSH_USER: ${{ secrets.SSH_USER }} - DEPLOY_HOST: ${{ steps.host.outputs.deploy_host }} - REPO: ${{ github.repository }} - BRANCH: ${{ github.ref_name }} - GHCR_USER: ${{ secrets.GHCR_USER }} - GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} - IMAGE_TAG: ${{ steps.vars.outputs.image_tag }} - ENV_FILE: ${{ steps.vars.outputs.env_file }} - STACK_NAME: ${{ steps.vars.outputs.stack_name }} - run: | - ssh "${SSH_USER}@${DEPLOY_HOST}" \ - "env GHCR_USER='${GHCR_USER}' GHCR_TOKEN='${GHCR_TOKEN}' IMAGE_TAG='${IMAGE_TAG}' ENV_FILE='${ENV_FILE}' STACK_NAME='${STACK_NAME}' bash -s -- '${REPO}' '${BRANCH}'" <<'EOS' - set -euo pipefail - - REPO="${1:?missing repo}" - BRANCH="${2:-main}" - - : "${IMAGE_TAG:?missing IMAGE_TAG}" - : "${GHCR_USER:?missing GHCR_USER}" - : "${GHCR_TOKEN:?missing GHCR_TOKEN}" - : "${STACK_NAME:?missing STACK_NAME}" - - REPO_NAME="${REPO##*/}" - APP_DIR="/srv/${STACK_NAME}" - REPO_SSH="git@github.com:${REPO}.git" - - ssh-keyscan -H github.com >> ~/.ssh/known_hosts 2>/dev/null - chmod 600 ~/.ssh/known_hosts - - if [[ ! -d "${APP_DIR}/.git" ]]; then - sudo mkdir -p "${APP_DIR}" - sudo chown -R "$USER:$USER" "${APP_DIR}" - git clone --branch "${BRANCH}" "${REPO_SSH}" "${APP_DIR}" - fi - - cd "${APP_DIR}" - git fetch --prune --tags origin - git checkout --detach "${IMAGE_TAG}" - - : "${ENV_FILE:?missing ENV_FILE}" - umask 077 - sops -d --input-type dotenv --output-type dotenv "$ENV_FILE" > .env - - # Init swarm if not already active - if ! sudo docker info --format '{{.Swarm.LocalNodeState}}' | grep -qx active; then - sudo docker swarm init - fi - - echo "$GHCR_TOKEN" | sudo docker login ghcr.io -u "$GHCR_USER" --password-stdin - - sudo docker pull "ghcr.io/${REPO}:${IMAGE_TAG}" - - # Deploy/update stack — export .env vars for compose interpolation - # (docker stack deploy does NOT read .env files automatically) - # IMAGE and IMAGE_TAG are set last to prevent .env from overriding them - sudo env \ - $(grep -v '^#' .env | grep -v '^$' | xargs) \ - IMAGE="ghcr.io/${REPO}" IMAGE_TAG="${IMAGE_TAG}" \ - docker stack deploy -c docker-compose.yml \ - --with-registry-auth --resolve-image always --prune "${STACK_NAME}" - - # Verify the deployed image matches what was built - DEPLOYED_IMAGE=$(sudo docker service inspect "${STACK_NAME}_app" --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}') - echo "Deployed image: ${DEPLOYED_IMAGE}" - if [[ "$DEPLOYED_IMAGE" != *"${IMAGE_TAG}"* ]]; then - echo "::warning::Deployed image tag doesn't match expected ${IMAGE_TAG}" - fi - - sudo docker stack services "${STACK_NAME}" - - # Wait for rollout - wait_rollout() { - local svc="$1" timeout="${2:-300}" - local end=$((SECONDS + timeout)) - while (( SECONDS < end )); do - local state - state="$(sudo docker service inspect "$svc" --format '{{if .UpdateStatus}}{{.UpdateStatus.State}}{{end}}' 2>/dev/null || echo "")" - echo " $svc: update_state=$state" - if [[ "$state" == "rollback_started" || "$state" == "rollback_completed" ]]; then - echo " ERROR: $svc rolled back!" - sudo docker service ps "$svc" --no-trunc --format '{{.Name}} {{.CurrentState}} {{.Error}}' | head -10 - return 1 - fi - if [[ "$state" == "completed" ]] || [[ -z "$state" ]]; then - # Verify all running tasks are healthy (not just started) - local unhealthy - unhealthy="$(sudo docker service ps "$svc" --filter desired-state=running --format '{{.CurrentState}}' 2>/dev/null | grep -cv '^Running' || true)" - if [[ "$unhealthy" == "0" ]]; then - echo " $svc rollout complete" - return 0 - fi - fi - sleep 10 - done - echo "Rollout timeout for $svc" - sudo docker service ps "$svc" --no-trunc --format '{{.Name}} {{.CurrentState}} {{.Error}}' | head -10 - return 1 - } - - wait_rollout "${STACK_NAME}_app" 300 - wait_rollout "${STACK_NAME}_cron" 120 - - # Cleanup old images - sudo docker image prune -a --filter "until=72h" -f - - echo "Deployed ${STACK_NAME} @ ${IMAGE_TAG} to $(hostname)" - EOS diff --git a/.gitignore b/.gitignore index a3d73bf..accafc1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,6 @@ package-lock.json .DS_Store coverage data/ -*.db -*.db-journal -*.db-wal planning/ .claude/ @@ -27,5 +24,7 @@ Thumbs.db *.log npm-debug.log* -# pnpm store cache (created by in-container installs) -.pnpm-store/ + +# Cloudflare local state and dev secrets (v2) +.wrangler/ +.dev.vars* diff --git a/.oxlintrc.json b/.oxlintrc.json index 4d256f8..6a78d54 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -3,7 +3,131 @@ "rules": { "no-unused-vars": "error", "no-console": "off", - "eqeqeq": ["error", "always", { "null": "ignore" }] + "eqeqeq": [ + "error", + "always", + { + "null": "ignore" + } + ] }, - "ignorePatterns": ["node_modules", "dist"] + "ignorePatterns": ["node_modules", "dist"], + "overrides": [ + { + "files": ["packages/protocol/src/**/*.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "drizzle-orm", + "drizzle-orm/*", + "@libsql/*", + "libsql", + "hono", + "hono/*", + "better-auth", + "better-auth/*", + "@better-auth/*", + "react", + "react-dom", + "react-router", + "@underlay/server", + "@underlay/server/*", + "@underlay/web", + "@underlay/web/*", + "@underlay/cli", + "@underlay/cli/*" + ], + "message": "@underlay/protocol loads in Node, Workers and browsers with no app around it: no database, server, auth or UI imports (plan decision 18)." + } + ] + } + ] + } + }, + { + "files": [ + "packages/protocol/src/repo/**/*.ts", + "packages/protocol/src/stores/{memory,r2,s3}.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "drizzle-orm", + "drizzle-orm/*", + "@libsql/*", + "libsql", + "hono", + "hono/*", + "better-auth", + "better-auth/*", + "@better-auth/*", + "react", + "react-dom", + "react-router", + "@underlay/server", + "@underlay/server/*", + "@underlay/web", + "@underlay/web/*", + "@underlay/cli", + "@underlay/cli/*", + "**/stores/**", + "node:*" + ], + "message": "Repositories work over any Store, and stores other than fileStore run on every platform: no node: imports, and repo/ doesn't import a store (plan decision 18)." + } + ] + } + ] + } + }, + { + "files": [ + "packages/protocol/src/{constants,file-refs,format,hash,input-rules,jcs,root,semver,sha256,utf8,validate}.ts", + "packages/protocol/src/tree/**/*.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "drizzle-orm", + "drizzle-orm/*", + "@libsql/*", + "libsql", + "hono", + "hono/*", + "better-auth", + "better-auth/*", + "@better-auth/*", + "react", + "react-dom", + "react-router", + "@underlay/server", + "@underlay/server/*", + "@underlay/web", + "@underlay/web/*", + "@underlay/cli", + "@underlay/cli/*", + "**/repo/**", + "**/stores/**", + "node:*" + ], + "message": "The format half of @underlay/protocol is pure: no stores, repositories, I/O or platform (plan decision 18)." + } + ] + } + ] + } + } + ] } diff --git a/.sops.yaml b/.sops.yaml index 47614a9..66c16f3 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -1,9 +1,9 @@ # SOPS configuration — specifies which age public keys can decrypt. -# Matches .env.local, .env.prod, and .env.dev files (both plain and .enc encrypted). +# Matches .env.local, .env.prod, .env.prod-v2, .env.dev and .env.staging files (both plain and .enc encrypted). # Generate a keypair: age-keygen -o key.txt creation_rules: - - path_regex: \.env\.(local|prod|dev)(\.enc)?$ + - path_regex: \.env\.(local|prod|prod-v2|dev|staging)(\.enc)?$ age: >- age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr, age1ysddqggsx3h8zkv7xn3z26sjak5pqms6pyqhnky9ukrvpk7es5jsayz8w7, diff --git a/Caddyfile b/Caddyfile deleted file mode 100644 index a2e9ca4..0000000 --- a/Caddyfile +++ /dev/null @@ -1,21 +0,0 @@ -# Host-level Caddy — manages TLS for all domains. -# Deploy to /etc/caddy/Caddyfile on the server. -# Reload with: systemctl reload caddy -# NOTE: Use 127.0.0.1 (not localhost) — Docker Swarm publishes on IPv4 only. - -# Single Hono server handles both SSR and API on one port. - -dev.underlay.org { - tls internal - reverse_proxy 127.0.0.1:3000 -} - -www.underlay.org { - tls internal - reverse_proxy 127.0.0.1:3001 -} - -mirror.underlay.org { - tls internal - reverse_proxy 127.0.0.1:3002 -} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 374a1ca..0000000 --- a/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -# --- Dev stage --- -FROM node:24-slim AS dev -WORKDIR /app -RUN corepack enable && corepack prepare pnpm@latest --activate -RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* -COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./ -RUN pnpm install -COPY . . -CMD ["pnpm", "dev:app"] - -# --- Build stage --- -FROM node:24-slim AS build -WORKDIR /app -RUN corepack enable && corepack prepare pnpm@latest --activate -RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* -COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./ -RUN pnpm install --frozen-lockfile -COPY . . -RUN pnpm build - -# --- Production stage --- -FROM node:24-slim AS production -WORKDIR /app -RUN corepack enable && corepack prepare pnpm@latest --activate -RUN apt-get update && apt-get install -y python3 make g++ curl && rm -rf /var/lib/apt/lists/* -COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./ -RUN pnpm install --frozen-lockfile --prod && apt-get purge -y python3 make g++ && apt-get autoremove -y -COPY --from=build /app/dist ./dist -COPY --from=build /app/server.ts ./server.ts -COPY --from=build /app/src/db ./src/db -COPY --from=build /app/src/lib ./src/lib -COPY --from=build /app/src/api ./src/api -COPY --from=build /app/tools ./tools -COPY --from=build /app/tsconfig.json ./tsconfig.json -COPY --from=build /app/drizzle.config.ts ./drizzle.config.ts -COPY --from=build /app/public ./public - -ENV NODE_ENV=production -EXPOSE ${PORT:-3000} -CMD ["node", "--import", "tsx/esm", "server.ts"] diff --git a/README.md b/README.md index 8597718..ec8a8e7 100644 --- a/README.md +++ b/README.md @@ -1,520 +1,75 @@ -

  Underlay

+

  Underlay

Underlay is a protocol for giving structured data a permanent address. You push JSON records and a JSON Schema. You get back a versioned, content-addressed snapshot you can point to forever. -Every piece of content — records, schemas, and files — is identified by its SHA-256 hash. Versions are manifests that reference these hashes. Storage is deduplicated globally, transfers only move data the other side doesn't have, and provenance is built in: any record can be traced back to every collection and version that includes it. +Every piece of content — records, schemas, and files — is identified by its SHA-256 hash. A version is a tree of those hashes with a single root hash, so versions share the data they have in common, transfers only move what the other side doesn't have, and provenance is built in: a record's hash finds the collections and versions that include it. -Schemas are first-class objects: inspectable, comparable, and alignable across independently authored datasets. Two collections that independently define the same Author type produce the same schema hash — alignment falls out of the data model automatically. The infrastructure doesn't need to solve interoperability. It provides enough structure that interoperability can be solved dynamically by the tools and models that consume the data. +Schemas are first-class objects: inspectable, comparable, and alignable across independently authored datasets. Two collections that independently publish an identical Author schema share its schema hash — alignment falls out of the data model automatically. The infrastructure doesn't need to solve interoperability. It provides enough structure that interoperability can be solved dynamically by the tools and models that consume the data. -The protocol is simple: push records in, pull records out, trust the versions. The intelligence lives in the actors, not the store. The reference implementation runs at [underlay.org](https://underlay.org). +The protocol is simple: push records in, pull records out, trust the versions. The intelligence lives in the actors, not the store. The reference implementation runs at [www.underlay.org](https://www.underlay.org). Built by [Knowledge Futures](https://www.knowledgefutures.org), a 501(c)(3) public charity. -## Quick Start +## Repository layout -### Prerequisites +This branch is Underlay v2: one codebase that runs on Cloudflare Workers (D1, R2, Queues) and, +for development and tests, on Node (SQLite, S3 or the filesystem). It is a pnpm workspace: -- [Node.js](https://nodejs.org/) 24+ (LTS) -- [Docker](https://www.docker.com/) and Docker Compose +| Package | What it is | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/protocol` (`@underlay/protocol`) | The format: canonical JSON, hashing, validation, trees, version roots, the repository layout, the signed version log, tree sync and `fsck`. Stores for memory, the filesystem, S3 and R2. Runs in Node, Workers and browsers. | +| `packages/server` (`@underlay/server`) | The Hono app: API, push and commit, files, jobs, billing counters, storage locations. Worker entry `src/worker.ts`, Node entry `src/node/main.ts`. Migrations in `drizzle/`. | +| `packages/web` (`@underlay/web`) | The UI: React Router pages rendered on the server by both entries, plus the client bundle. | +| `packages/cli` (`@underlay/cli`) | The command line: a local repository, pull by tree sync, push by delta push. | +| `packages/migrate` (`@underlay/migrate`) | Converts a v1 (Postgres) instance into v2. | -### Development +`docs/protocol-v2.md` is the specification of Underlay protocol v2, with test vectors in +`packages/protocol/test/vectors/`. `docs/v1-read-api.md` is the inventory of v1's read API that +v2 was built against, and what v2 changes. -```bash -git clone https://github.com/knowledgefutures/underlay.git -cd underlay -./dev.sh -``` - -This starts: +## Development -- **PostgreSQL 17** on port 5433 (host) → 5432 (container) -- **Underlay** on port 4100 - -For team members with SOPS keys, the dev script auto-decrypts `.env.local` from `.env.local.enc`. External contributors should run `cp .env.test .env.local` first. - -### Without Docker +Node 24 and pnpm 10. ```bash pnpm install -cp .env.test .env.local -# Edit .env.local with your Postgres and S3 connection strings -pnpm db:migrate -pnpm db:seed -pnpm dev:app +pnpm typecheck # every package +pnpm test # every package's tests +pnpm lint && pnpm fmt:check ``` -### Default Seed User - -The seed script creates a "Knowledge Futures" org with sample collections. -In production, user accounts are created automatically on first sign-in via [KF Auth](https://auth.knowledgefutures.org) (OIDC SSO). - -## Content-Addressed Storage - -Everything in Underlay is content-addressed by SHA-256: - -- **Records** are stored as objects in a global `record_objects` table, keyed by the hash of their canonical JSON (`{"id":...,"type":...,"data":...}`). The same record in ten collections is stored once. -- **Schemas** are stored in a global `schemas` table, keyed by content hash. Two collections that define the same type share the same schema row. -- **Files** are stored in S3, keyed by SHA-256 of their bytes. -- **Versions** are manifests — join tables (`version_records`, `version_schemas`, `version_files`) that reference content by hash. Creating a new version that shares 99% of its records with the previous version adds only the new records to storage. `version_file_refs` indexes the `$file` references in a version's records at commit, so file access checks never scan record bodies. - -This architecture enables hash negotiation for push and pull (only transfer what the other side doesn't have), provenance (which collections contain this exact record), and forking (copy the manifest, not the data). - -## CLI - -The CLI wraps the same versioning logic as the server: hashing, diffing, semver derivation. Versions exist locally in a `.underlay/` directory. You can commit multiple times before pushing, inspect history offline, and push when ready. +Run the app on Node with SQLite and blobs on disk (port 4200); migrations apply on start: ```bash -pnpm cli init my-collection -pnpm cli schema-set schema.json -pnpm cli add records.jsonl -pnpm cli status -pnpm cli commit -m "initial load" -pnpm cli remote add origin https://underlay.org -t ul_mykey -c my-org/my-collection -pnpm cli push -``` - -The CLI source lives in `src/cli/`. For npm distribution, `packages/cli/` is a thin publish wrapper that uses esbuild to bundle into a standalone `@underlay/cli` package. - -### Local store format - -``` -.underlay/ - config.json # remotes (url, token, collection) - HEAD # current version semver (e.g. v1.2.0) - objects/ab/cd/abcd1234... # record content, keyed by hash - schemas/ef/01/ef012345... # schema JSON, keyed by hash - versions/v1.0.0.json # version manifest (schemas, records, files, semver) - staging/records.jsonl # staged records before commit - staging/schema.json # staged schema before commit -``` - -## Architecture - -| Layer | Technology | -| ------------ | ------------------------------------------------------------- | -| Server | Hono 4 + @hono/node-server | -| Frontend | React 19 + React Router v7 (SSR + client hydration) | -| Styling | Tailwind CSS 4 (@tailwindcss/vite) | -| Build | Vite 6 (client + SSR bundles) | -| Database | PostgreSQL 17 + Drizzle ORM | -| File Storage | S3-compatible (Cloudflare R2 in production) | -| Auth | KF Auth SSO (OIDC) for web sessions + API keys (programmatic) | -| Deployment | Docker Swarm on Hetzner, Caddy reverse proxy, Cloudflare DNS | -| CI/CD | GitHub Actions → GHCR → SSH → `docker stack deploy` | -| Secrets | SOPS + age encryption | - -The app runs as a single Hono server on one port (default 3000). In dev, Vite runs in middleware mode for HMR. In production, Vite builds client and SSR bundles that Hono serves directly. - -## Project Structure - +pnpm --filter @underlay/web build +DB_URL=file:/tmp/ul.sqlite BLOB_DIR=/tmp/ul-blobs npx tsx packages/server/src/node/main.ts ``` -server.ts # Hono entry point (API routes + SSR) -vite.config.ts # Vite config (React, Tailwind, SSR) -src/ -├── entry-client.tsx # Client hydration entry -├── entry-server.tsx # SSR rendering (renderToPipeableStream) -├── App.tsx # React Router routes (filesystem-based) -├── route-gen.ts # Filesystem → route pattern conversion (wires *.data.ts loaders) -├── global.css # Tailwind theme -├── api/ # API route handlers -│ ├── auth.server.ts # API auth middleware (API keys, internal tokens) -│ ├── rate-limit.server.ts # Global API rate limiting (60/min anon, 5k/min authed) -│ ├── accounts.ts # Account/org profiles, members, avatars -│ ├── agent.ts # Agent share page (token-authenticated HTML instructions) -│ ├── collections.ts # Collection CRUD + export, transfer, fork -│ ├── discussion.ts # Page-anchored discussion threads -│ ├── organizations.ts # Organization creation -│ ├── webhooks.ts # Collection webhooks + delivery log -│ ├── versions.ts # Version read APIs (manifest, records, diff) + privacy filtering -│ ├── negotiate.ts # Push protocol: hash negotiation, record upload, commit -│ ├── records.ts # Provenance + batch record fetch -│ ├── files.ts # Content-addressed file storage -│ ├── schemas.ts # Schema discovery, search, labeling -│ ├── query.ts # SQL query tool (SQLite export + LLM SQL generation) -│ ├── ark.ts # ARK identifier management -│ ├── ark-middleware.server.ts # ARK resolution middleware -│ ├── kf-summary.ts # Internal summary endpoint for KF dashboards -│ ├── admin.ts # Admin endpoints (mirror mode) -│ └── health.ts # Health check -├── db/ -│ ├── schema.ts # Drizzle table definitions -│ ├── client.server.ts # Database client -│ ├── migrate.ts # Migration runner -│ ├── seed.ts # Seed data (destructive with --force) -│ ├── seedKfCollections.ts # Non-destructive seed of the KF sample collections -│ ├── seed-helpers.ts # Record/schema insertion shared by both seeds -│ └── migrations/ # Generated SQL migrations -├── lib/ -│ ├── core/ # Pure functions shared by server and CLI (each with *.test.ts) -│ │ ├── hash.ts # hashRecord, hashSchema (SHA-256) -│ │ ├── semver.ts # deriveSemver -│ │ ├── version-hash.ts # computeVersionHash, computePublicHash -│ │ ├── privacy.ts # getPrivateTypes, getPrivateFields, filterRecordData -│ │ ├── validate.ts # AJV schema validation -│ │ ├── types.ts # Shared type definitions -│ │ └── index.ts # Re-exports -│ ├── version-helpers.server.ts # Re-exports core + DB-dependent helpers (collection access checks) -│ ├── collection-access.ts # Access decisions behind those checks (write role, key scope, negotiate sessions) -│ ├── auth.ts # better-auth config (KF Auth OIDC, API keys, orgs) -│ ├── auth.server.ts # Session helpers -│ ├── auth-client.ts # better-auth React client -│ ├── auth-middleware.ts # React Router requireAuth middleware -│ ├── auth-internal.server.ts # KF Auth internal API client (optional) -│ ├── mirror-config.ts # Mirror mode config (UNDERLAY_* env vars) -│ ├── mirror-sync.ts # Server-to-server mirroring -│ ├── sqlite-gen.ts # Version → SQLite database generation -│ ├── s3.ts # S3 client -│ ├── webhooks.server.ts # Webhook delivery, retries, SSRF guard -│ ├── slug.ts # Organization slug rules (reserved names) -│ ├── query-params.ts # ?limit / ?offset parsing -│ └── ark.ts # ARK identifier utilities -├── cli/ # CLI source (local versioning + push/pull) -│ ├── cli.ts # Commander entry point -│ ├── commands/ # init, schema-set, add, status, commit, log, diff, remote, push, pull -│ └── lib/ # Local store, config, staging helpers -├── routes/ # React pages (filesystem routing; sibling *.data.ts = server loaders) -│ ├── index.tsx # Landing page -│ ├── explore.tsx # Browse public collections -│ ├── dashboard.tsx # User's collections -│ ├── protocol.tsx # Protocol specification -│ ├── query.tsx # SQL query explorer -│ ├── records/[hash].tsx # Record detail + provenance -│ ├── schemas/ # Schema browser -│ ├── settings/ # Account settings + API keys -│ ├── docs/ # Documentation -│ └── [owner]/ # Dynamic owner routes -│ ├── index.tsx -│ ├── settings/ # Org settings -│ │ ├── index.tsx -│ │ ├── members.tsx -│ │ └── keys.tsx -│ └── [collection]/ -│ ├── index.tsx -│ ├── versions.tsx -│ ├── schemas.tsx -│ ├── v/[n].tsx -│ ├── diff.tsx -│ └── settings.tsx -├── components/ # Shared React components -packages/ -└── cli/ # npm publish wrapper (@underlay/cli) - └── package.json # esbuild bundles src/cli → dist/cli.js -public/ -├── llms.txt # Machine-readable API docs for LLMs -tools/ -├── backupDb.ts # Postgres backup → S3 -├── restore.ts # Restore database from an S3 backup -├── pruneBackups.ts # Retention pruning of old backups -├── cleanupSessions.ts # Prune expired negotiate sessions; fail stranded finalizes -├── pruneWebhookLogs.ts # Prune old webhook delivery logs -├── verifyRecordSharing.ts # Check metadata-patch record sharing (needs a scratch DB) -├── verifyFileRefs.ts # Check file access via version_file_refs matches the old scans (scratch DB) -├── seedMirror.ts # Minimal seed for mirror instances -└── cron.ts # Scheduled tasks (backup, prune backups, session cleanup, webhook-log pruning, mirror sync) -``` - -## Protocol and Documentation - -The protocol and the platform are documented together: - -| Resource | URL | Purpose | -| ------------- | ------------------------------------------ | ------------------------------------------------------------------- | -| Protocol spec | [/protocol](https://underlay.org/protocol) | Full protocol: data model, hashing, push, pull, provenance, privacy | -| User docs | [/docs](https://underlay.org/docs) | Concepts, integration guide, API reference, quickstart | -| llms.txt | [/llms.txt](https://underlay.org/llms.txt) | Machine-readable API docs for LLMs and bots | - -### Key API endpoints - -All pushes use the negotiate protocol — a three-step flow similar to git's pack negotiation. Two of -those steps have a chunked form for collections that don't fit in a single request: the manifest can -upload in pieces, and the commit can run in the background. A chunked, asynchronous push produces -the same version hash as the simple one. - -A session tracks its progress in two counters on `negotiate_sessions` — `manifest_received` (distinct -hashes in the manifest) and `manifest_needed` (of those, still awaiting a record). Each manifest -chunk and records batch moves them by the rows it actually added or flipped, so the status poll and -the commit checks never recount the manifest, however large the push. - -| Endpoint | Purpose | -| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `POST .../versions/negotiate` | Start a push session (server returns which hashes it needs) | -| `POST .../versions/negotiate/:sessionId/manifest` | Upload the manifest in NDJSON chunks, when it is too large to send in one body | -| `POST .../versions/negotiate/:sessionId/records` | Send only the needed records (NDJSON) | -| `POST .../versions/negotiate/:sessionId/commit` | Validate, hash, and create the immutable version. `?async=true` returns 202 and finalizes in the background | -| `GET .../versions/negotiate/:sessionId` | Session status, and the result or error of an async commit | -| `GET .../versions/:semver/manifest` | Version manifest (add `?since=` for delta; both keyset-paginated) | -| `GET .../versions/:semver/records` | Paginated records | -| `GET .../versions/:semver/records.ndjson` | Every record streamed as NDJSON in one request — the bulk read path, resumable via `?after=` | -| `GET .../export` | Version archive (`.tar.gz`), streamed as it's read; byte-identical per version. `?version=` picks one | -| `GET .../versions/:semver/diff?from=...` | Diff between two versions | -| `POST /api/records/batch` | Fetch records by hash (JSONL stream) | -| `GET /api/records/:hash/provenance` | Find all collections containing a record | -| `POST .../fork` | Fork a collection (copies manifest, not data; 403 for a non-member if the source holds private content) | -| `GET /api/schemas` | Search schemas across all collections | - -## Privacy - -Privacy is part of the protocol, not just a hosted-instance feature. Four layers compose — a reader sees content only if it passes all four: - -- **Private collections**: `collections.public = false` — the whole collection 404s for non-members, and nothing below is evaluated. ARK identifiers into a private collection refuse to resolve. -- **Private types**: `"private": true` on a schema root hides all records of that type from public readers. -- **Private fields**: `"private": true` on a schema property strips that field from public responses. -- **Private records**: `"private": true` on a **manifest entry** when pushing (not on the record body — a `private` key there is ignored) hides that specific record. - -Record-level privacy is stored **per version**, on that version's reference to the record (`version_records.private`), never on the globally deduplicated record object — so two collections holding byte-identical content can disagree about privacy. Two consequences follow: **privacy is re-declared on every push (omitting the flag means public, not "unchanged")**, and **redaction is forward-only** — older versions are immutable and keep serving the record, and a file stays downloadable while any older version references it publicly. - -The `private` flag is not part of the record hash — a record's content identity doesn't change when you change who can see it. Each version has two hashes: a **private hash** (all content, used by owners for integrity) and a **public hash** (excludes private types, fields, and records, verifiable by anyone). A version is identified by **both**: a push is a duplicate only when both match, which is what makes a privacy-only re-push a legitimate new version rather than a "no changes" conflict. - -Privacy filtering is implemented in `src/lib/core/privacy.ts` (pure functions) and enforced at the API layer in every module that returns record bodies, hashes, schemas, file lists, diffs, or counts: `versions.ts`, `collections.ts` (export, browse, fork), `records.ts`, `query.ts`, `files.ts`, `schemas.ts`, and `ark.ts`. - -### Access control - -- **API keys** carry `metadata.scope`. Because that metadata is client-supplied, self-service creation is clamped at `write` — requesting `admin` yields `write`, and admin keys are minted server-side only. -- **Collection-scoped keys** (share links, agent links) carry `metadata.collectionIds` and are confined to those collections; account- and org-level endpoints refuse them outright. -- **`?token=` capability URLs** authenticate `GET`/`HEAD` only, so a link prefetch can never drive a mutation. Share-link clients calling a POST send the token as a `Bearer` header. -- **Files** are never served directly from storage: every read is access-checked and answered with a short-lived presigned URL. - -## Schema System - -Underlay uses **globally deduplicated, content-addressed schemas** for record validation and interoperability. - -- Each record type in a collection has its own JSON Schema, stored as an immutable, content-addressed row in the global `schemas` table. -- A version declares its full set of type-to-schema bindings via the `version_schemas` join table. -- If two collections define the same fields and types for a record type, they produce the same schema hash. Alignment is automatic. -- Schemas are never modified. Evolving a type produces a new hash and a new row. - -### Push payload (negotiate) - -```json -{ - "base_version": null, - "schemas": { - "Author": { "type": "object", "properties": { "name": { "type": "string" } } }, - "Pub": { - "type": "object", - "properties": { - "title": { "type": "string" }, - "authorId": { "type": "string", "x-ref-type": "Author" } - } - } - }, - "manifest": [{ "id": "auth-1", "type": "Author", "hash": "abc123..." }] -} -``` - -The server replies with the record hashes it doesn't have; the client streams just those records (NDJSON) and commits. -### Relationship annotations +`pnpm --filter @underlay/server dev:node` runs the same entry under `tsx watch`. +`packages/server/src/node/main.ts` lists the Node environment variables (S3, KF Auth, signing +and location keys); without them it uses development secrets and a throwaway signing key, and +sign-in points at a local KF Auth. The Node entry is for development only. -Fields that hold record IDs of another type use `"x-ref-type": "TypeName"` to document the relationship. This enables linked-record navigation in the UI and helps LLMs understand the relational graph. - -### Schema labeling - -Schemas can be labeled post-hoc with human-readable names or URIs (e.g. `schema.org/Person`, `dc.author.v1`). Labels enable discovery across collections without upfront coordination. - -- `POST /api/schemas/:id/labels` - Add a label -- `DELETE /api/schemas/:id/labels/:label` - Remove a label -- `GET /api/schemas?label=...` - Search by label -- Labels are injected as `x-underlay-labels` in schema exports (opt-out via `?raw=true`) - -### Versioning semantics - -- **Major bump**: Schema set changed (type added, removed, or schema modified) -- **Minor bump**: Records changed, schema set identical -- **Patch bump**: Only metadata changed (readme, message) +Other checks CI runs: `pnpm --filter @underlay/protocol check-browser` (the browser bundle), +`pnpm --filter @underlay/cli build`, and `pnpm --filter @underlay/web build && pnpm --filter +@underlay/web smoke` (server-rendered pages). ## Deployment -### Infrastructure - -- **Hetzner** - Single box (8 vCPU, 16GB RAM) running Docker Swarm -- **Caddy** - Host-level reverse proxy, TLS via `tls internal` (Cloudflare Full mode) -- **Cloudflare** - DNS + CDN + DDoS protection -- **R2** - Object storage (zero egress fees), two buckets: - - `S3_BUCKET` (**private** — public access disabled, no custom domain): `files/` content-addressed immutable uploads, `_backups/` compressed Postgres dumps. Every read is a short-lived presigned URL minted after an access check; there is no stable public file URL. - - `S3_PUBLIC_BUCKET` (world-readable, fronted by `ASSETS_BASE_URL`): avatars and other inherently-public static assets. - -### Stacks - -Two Docker Swarm stacks run on the same box: - -| Stack | Domain | Host Port | Purpose | -| --------------- | ---------------- | --------- | ---------- | -| `underlay-prod` | www.underlay.org | 3001 | Production | -| `underlay-dev` | dev.underlay.org | 3000 | Staging | - -Container-internal port is always 3000. Host port is configured via `PORT` in .env files. - -### CI/CD Flow - -1. Push to `main` → deploys to `dev.underlay.org` -2. Create a release/tag → deploys to `www.underlay.org` -3. Manual dispatch → choose environment - -The workflow: build Docker image → push to GHCR → decrypt env file for `DEPLOY_HOST` → SSH to server → `docker stack deploy` → wait for healthy rollout. - -Required GitHub secrets: `SSH_PRIVATE_KEY`, `SSH_USER`, `GHCR_USER`, `GHCR_TOKEN`, `SOPS_AGE_SECRET_KEY`. - -### Docker Compose Files - -| File | Purpose | -| ----------------------------- | ---------------------------------------------- | -| `docker-compose.yml` | Deployed stacks (prod & dev via Swarm) | -| `docker-compose.local.yml` | Local development (source-mounted, hot reload) | -| `docker-compose.withauth.yml` | Self-hosted: app + KF Auth + MinIO + Caddy | - -### Self-Hosting - -Run the Underlay with a bundled auth server (no external auth provider needed): - -```bash -DOMAIN=https://my-instance.com docker compose -f docker-compose.withauth.yml up -d -``` - -This starts Postgres, KF Auth (auth + account), MinIO (S3-compatible storage), the Underlay app, and Caddy with automatic TLS. On first boot, an init container auto-generates all secrets (session keys, OAuth client credentials, S3 credentials). - -Optional configuration (via environment variables or `.env` file): - -- `SMTP_*` vars for email delivery (password resets, invitations) -- `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` for GitHub login -- `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` for Google login -- `ORCID_CLIENT_ID`/`ORCID_CLIENT_SECRET` for ORCID login - -To use external S3 (AWS, Cloudflare R2, etc.) instead of bundled MinIO, remove the `minio` and `minio-init` services and set `S3_BUCKET`, `S3_REGION`, `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` in the app environment. - -Supporting files live in `selfhost/` (Caddyfile, Postgres init script). See [/docs/self-host](https://underlay.org/docs/self-host) for full details. - -## Environment Variables - -### Core - -| Variable | Description | -| ---------------- | ------------------------------------------------------------------------------------------------------ | -| `DATABASE_URL` | PostgreSQL connection string | -| `SESSION_SECRET` | Secret for signing session cookies (**required in production** — the app throws at startup without it) | -| `PORT` | Server port (default: 3000) | -| `APP_URL` | Public base URL of this instance (default: `http://localhost:4100`) | - -### S3 storage - -| Variable | Description | -| ------------------------ | --------------------------------------------------------------------------------------------------- | -| `S3_BUCKET` | Private bucket for collection files and backups. Public access must be OFF | -| `S3_PUBLIC_BUCKET` | World-readable bucket for avatars and similar assets (default: `underlaypublic`) | -| `S3_PRESIGN_TTL_SECONDS` | Lifetime of presigned file-download URLs, in seconds (default: `300`) | -| `S3_REGION` | S3 region (`auto` for R2) | -| `S3_ENDPOINT` | S3 endpoint URL | -| `S3_ACCESS_KEY` | S3 access key | -| `S3_SECRET_KEY` | S3 secret key | -| `ASSETS_BASE_URL` | Public base URL for uploaded assets like avatars (optional, default: `https://assets.underlay.org`) | - -### Auth (KF Auth OIDC) - -| Variable | Description | -| -------------------------- | -------------------------------------------------------------------------------- | -| `OIDC_ISSUER_URL` | KF Auth issuer URL | -| `OIDC_ISSUER_INTERNAL_URL` | Issuer URL for server-to-server calls (optional, defaults to `OIDC_ISSUER_URL`) | -| `OIDC_CLIENT_ID` | OAuth client ID (default: `kf_underlay`) | -| `OIDC_CLIENT_SECRET` | OAuth client secret | -| `OIDC_ACCOUNT_URL` | KF Account UI URL (account management links) | -| `AUTH_INTERNAL_API_KEY` | Key for KF Auth's internal API (optional; also authenticates `/api/kf/summary`) | -| `AUTH_INTERNAL_API_URL` | KF Auth internal API base URL (optional, defaults to `OIDC_ISSUER_INTERNAL_URL`) | -| `INTERNAL_API_TOKEN` | Legacy `x-internal-token` for internal service calls (optional) | - -### Optional features - -| Variable | Description | -| --------------------------- | ------------------------------------------------------------------------- | -| `ARK_DEFAULT_NAAN` | Default NAAN for ARK identifiers (placeholder `12345` if unset) | -| `CF_ACCOUNT_ID` | Cloudflare account ID for LLM-powered natural-language SQL (optional) | -| `CF_API_TOKEN` | Cloudflare API token for LLM-powered natural-language SQL (optional) | -| `UNDERLAY_MODE` | `origin` (default) or `mirror` — read-only mirror of an upstream instance | -| `UNDERLAY_NODE_NAME` | Display name for this mirror node | -| `UNDERLAY_UPSTREAM` | Upstream Underlay URL to mirror from | -| `UNDERLAY_UPSTREAM_API_KEY` | API key for the upstream instance | -| `UNDERLAY_SYNC_SCHEDULE` | Cron schedule for mirror sync (default: `0 0 * * 0`) | -| `MIRROR_ADMIN_EMAILS` | Comma-separated emails allowed to use the mirror admin UI/API | -| `CORS_ORIGINS` | Extra allowed CORS origins, comma-separated (APP_URL is always allowed) | -| `MAX_FILE_UPLOAD_BYTES` | Max file upload size in bytes (default: 100 MB) | -| `MAX_RECORDS_BATCH_BYTES` | Max body of one negotiate records batch in bytes (default: 128 MB) | - -`NODE_ENV` is set in `docker-compose.yml` `environment:` block (not in .env files). - -## Scripts +Each deployment is a wrangler env in `packages/server/wrangler.jsonc` with its own Worker, D1 +database, bucket and queues: `staging` (staging.underlay.org) and `prod` (www.underlay.org): ```bash -# Development -pnpm dev # Start full local stack (Docker) -pnpm dev:app # Start server without Docker -pnpm build # Build for production (client + SSR) -pnpm start # Start production server -pnpm cli # Run CLI locally (e.g. pnpm cli init, pnpm cli add) - -# Code quality -pnpm typecheck # TypeScript type checking -pnpm lint # Lint with oxlint -pnpm fmt # Format with oxfmt -pnpm fmt:check # Check formatting -pnpm test # Run tests (Vitest) -pnpm test:watch # Run tests in watch mode - -# Database -pnpm db:generate # Generate Drizzle migrations from schema changes -pnpm db:migrate # Run pending migrations -pnpm db:seed # Seed database -pnpm db:seed-kf # Add the KF sample collections (non-destructive) - -# Tools -pnpm tool:backup # Manual database backup to S3 -pnpm tool:restore # List S3 backups; restore one with `-- --yes` -pnpm tool:pruneBackups # Prune old backups (supports `-- --dry-run`) -pnpm tool:cleanupSessions # Prune expired negotiate sessions -pnpm tool:pruneWebhookLogs # Prune old webhook delivery logs -pnpm tool:verifyRecordSharing # Verify metadata-patch record sharing (scratch DB) -pnpm tool:verifyFileRefs # Verify file access/listing via version_file_refs (scratch DB) -pnpm tool:seed-mirror # Seed a mirror instance (admin org only) - -# Secrets (SOPS + age) -pnpm secrets:encrypt:local # Encrypt .env.local → .env.local.enc -pnpm secrets:encrypt:prod # Encrypt .env.prod → .env.prod.enc -pnpm secrets:encrypt:dev # Encrypt .env.dev → .env.dev.enc -pnpm secrets:decrypt:local # Decrypt .env.local.enc → .env.local -pnpm secrets:decrypt:prod # Decrypt .env.prod.enc → .env.prod -pnpm secrets:decrypt:dev # Decrypt .env.dev.enc → .env.dev +pnpm --filter @underlay/web build +cd packages/server +npx wrangler d1 migrations apply underlay-staging --env staging --remote # new migrations only +npx wrangler deploy --env staging ``` -## Maintenance Checklist - -When adding or changing features, update these locations: - -| What | Where | Purpose | -| ----------------- | --------------------------------------- | ------------------------------------------ | -| Protocol spec | `src/routes/protocol.tsx` | Protocol documentation page | -| API documentation | `public/llms.txt` | Machine-readable docs for LLMs and bots | -| Concepts | `src/routes/docs/concepts.tsx` | Core concepts explanation | -| API reference | `src/routes/docs/api/*.tsx` | Endpoint-level docs with examples | -| Integration guide | `src/routes/docs/integration.tsx` | Developer onboarding guide | -| Quick start | `src/routes/docs/quickstart.tsx` | Getting started tutorial | -| Self-hosting | `src/routes/docs/self-host.tsx` | Deployment instructions | -| DB schema | `src/db/schema.ts` → `pnpm db:generate` | Schema changes need a migration | -| Core library | `src/lib/core/` | Hashing, semver, privacy, validation | -| CLI commands | `src/cli/commands/` | Local versioning and sync | -| Schema discovery | `src/api/schemas.ts` | Schema search, labeling, cross-referencing | -| Encrypted secrets | `.env.{local,dev,prod}.enc` | Re-encrypt after changing .env files | - -### Privacy features - -Privacy is part of the protocol. The system supports three levels (type-level, field-level, record-level) via `"private": true` annotations. When changing how privacy works, update: - -- `src/lib/core/privacy.ts` - pure filtering functions (shared by server and CLI) -- `src/api/versions.ts` - API-level filtering -- `src/api/files.ts` - file access checks -- `src/api/schemas.ts` - public schema filtering -- `src/routes/protocol.tsx` - protocol spec -- `public/llms.txt` - Privacy section -- `src/routes/docs/concepts.tsx` - Privacy section -- `src/routes/docs/integration.tsx` - Privacy section +Secrets are SOPS-encrypted per deployment (`.env.staging.enc`); decrypt them into the shell, never +to a file. Add migrations with `cd packages/server && npx drizzle-kit generate --name `; +never edit one that a deployment has applied. ## License -MIT +MIT. See [LICENSE](LICENSE). diff --git a/dev.sh b/dev.sh deleted file mode 100755 index d094d6b..0000000 --- a/dev.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")" - -# Decrypt local env if needed -if [[ -f .env.local.enc ]] && [[ ! -f .env.local ]]; then - sops -d --input-type dotenv --output-type dotenv --output .env.local .env.local.enc -fi - -# Load env vars -set -a -[[ -f .env.local ]] && source .env.local -set +a - -# Find an available port, incrementing from PORT (default 4100) -BASE_PORT="${PORT:-4100}" -PORT="$BASE_PORT" -while lsof -iTCP:"$PORT" -sTCP:LISTEN -t &>/dev/null; do - ((PORT++)) -done -if [[ "$PORT" -ne "$BASE_PORT" ]]; then - echo "Port $BASE_PORT in use, using $PORT" -fi -export PORT - -trap "docker compose -f docker-compose.local.yml down" EXIT - -docker compose --env-file .env.local -f docker-compose.local.yml up --build --attach app diff --git a/docker-compose.local.yml b/docker-compose.local.yml deleted file mode 100644 index 42caeee..0000000 --- a/docker-compose.local.yml +++ /dev/null @@ -1,63 +0,0 @@ -# Local development compose — source-mounted for fast reload. -# Start with: ./dev.sh -# Access at localhost:${PORT:-4100} - -name: underlay-local - -services: - db: - image: postgres:17-alpine - environment: - POSTGRES_USER: underlay - POSTGRES_PASSWORD: underlay - POSTGRES_DB: underlay - # Sized for bulk ingest as well as everyday dev. A multi-million-record push - # sorts the whole record set twice to fold the version digests, and builds - # indexes over it — at 8MB work_mem that spills to disk in many small merge - # passes. max_connections is low, so the worst case here is bounded. - command: > - postgres - -c shared_buffers=1GB - -c effective_cache_size=3GB - -c work_mem=64MB - -c maintenance_work_mem=512MB - -c max_wal_size=4GB - -c checkpoint_completion_target=0.9 - -c max_connections=50 - # Matches the production stack: Docker's 64 MB /dev/shm default is too small - # for Postgres parallel query workers once tables get large. - shm_size: 1gb - ports: - - '5433:5432' - volumes: - - pgdata:/var/lib/postgresql/data - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U underlay'] - interval: 5s - timeout: 3s - retries: 5 - - app: - build: - context: . - dockerfile: Dockerfile - target: dev - ports: - - '${PORT:-4100}:${PORT:-4100}' - - '24688:24688' - volumes: - - .:/app - - app_node_modules:/app/node_modules - env_file: - - .env.local - environment: - PORT: ${PORT:-4100} - DATABASE_URL: postgresql://underlay:underlay@db:5432/underlay - command: sh -c "pnpm db:migrate && pnpm db:seed && pnpm dev:app" - depends_on: - db: - condition: service_healthy - -volumes: - pgdata: - app_node_modules: diff --git a/docker-compose.withauth.yml b/docker-compose.withauth.yml deleted file mode 100644 index 22cd682..0000000 --- a/docker-compose.withauth.yml +++ /dev/null @@ -1,240 +0,0 @@ -# docker-compose.withauth.yml — Self-hosted Underlay with bundled auth. -# -# Runs: Underlay app + KF Auth (auth + account) + Postgres + MinIO (S3) + Caddy -# One command: docker compose -f docker-compose.withauth.yml up -# -# First run generates secrets automatically via the init container. -# Set DOMAIN=https://your-domain.com in your shell or .env file. -# -# To use external S3 instead of bundled MinIO, remove the minio service and set -# S3_BUCKET, S3_REGION, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY in the app -# environment block (or pass them as env vars that the init container writes -# into .env.app). - -name: underlay-withauth - -services: - # --- Init container: generates secrets + config --- - withauth-init: - image: alpine:3.20 - entrypoint: /bin/sh - command: - - -c - - | - if [ -f /config/.env.withauth ]; then - echo "Config already exists, skipping init." - exit 0 - fi - apk add --no-cache openssl - AUTH_SECRET=$$(openssl rand -hex 32) - SESSION_SECRET=$$(openssl rand -hex 32) - CLIENT_SECRET=$$(openssl rand -hex 32) - INTERNAL_KEY=$$(openssl rand -hex 32) - MINIO_ACCESS=$$(openssl rand -hex 16) - MINIO_SECRET=$$(openssl rand -hex 32) - printf '%s\n' \ - "BETTER_AUTH_SECRET=$$AUTH_SECRET" \ - "BETTER_AUTH_URL=$${DOMAIN:-http://localhost}/auth" \ - "ACCOUNT_URL=$${DOMAIN:-http://localhost}/account" \ - "DATABASE_URL=postgres://kfauth:kfauth@postgres:5432/kfauth" \ - "SMTP_HOST=$${SMTP_HOST:-localhost}" \ - "SMTP_PORT=$${SMTP_PORT:-25}" \ - "SMTP_FROM=$${SMTP_FROM:-noreply@localhost}" \ - "SMTP_USER=$${SMTP_USER:-}" \ - "SMTP_PASS=$${SMTP_PASS:-}" \ - "KF_INTERNAL_API_KEY=$$INTERNAL_KEY" \ - "BASE_PATH=/auth" \ - "APPS_REGISTRY_FILE=/config/apps.withauth.yaml" \ - "GITHUB_CLIENT_ID=$${GITHUB_CLIENT_ID:-}" \ - "GITHUB_CLIENT_SECRET=$${GITHUB_CLIENT_SECRET:-}" \ - "GOOGLE_CLIENT_ID=$${GOOGLE_CLIENT_ID:-}" \ - "GOOGLE_CLIENT_SECRET=$${GOOGLE_CLIENT_SECRET:-}" \ - "ORCID_CLIENT_ID=$${ORCID_CLIENT_ID:-}" \ - "ORCID_CLIENT_SECRET=$${ORCID_CLIENT_SECRET:-}" \ - "AUTH_SERVICE_NAME=$${AUTH_SERVICE_NAME:-Underlay Auth}" \ - > /config/.env.withauth - printf '%s\n' \ - "- client_id: underlay" \ - " client_secret: $$CLIENT_SECRET" \ - " redirect_uris:" \ - " - $${DOMAIN:-http://localhost}/api/auth/oauth2/callback/kf-auth" \ - " skip_consent: true" \ - " display_name: \"Underlay\"" \ - " allow_sign_up: true" \ - > /config/apps.withauth.yaml - printf '%s\n' \ - "SESSION_SECRET=$$SESSION_SECRET" \ - "OIDC_ISSUER_URL=$${DOMAIN:-http://localhost}/auth" \ - "OIDC_ISSUER_INTERNAL_URL=http://auth:3000" \ - "OIDC_ACCOUNT_URL=$${DOMAIN:-http://localhost}/account" \ - "OIDC_CLIENT_ID=underlay" \ - "OIDC_CLIENT_SECRET=$$CLIENT_SECRET" \ - "AUTH_INTERNAL_API_KEY=$$INTERNAL_KEY" \ - "DATABASE_URL=postgres://kfauth:kfauth@postgres:5432/app" \ - "S3_BUCKET=underlay" \ - "S3_PUBLIC_BUCKET=underlaypublic" \ - "S3_REGION=us-east-1" \ - "S3_ENDPOINT=http://minio:9000" \ - "S3_ACCESS_KEY=$$MINIO_ACCESS" \ - "S3_SECRET_KEY=$$MINIO_SECRET" \ - > /config/.env.app - printf '%s\n' \ - "MINIO_ROOT_USER=$$MINIO_ACCESS" \ - "MINIO_ROOT_PASSWORD=$$MINIO_SECRET" \ - > /config/.env.minio - echo "Init complete." - volumes: - - withauth-config:/config - environment: - - DOMAIN - - SMTP_HOST - - SMTP_PORT - - SMTP_FROM - - SMTP_USER - - SMTP_PASS - - GITHUB_CLIENT_ID - - GITHUB_CLIENT_SECRET - - GOOGLE_CLIENT_ID - - GOOGLE_CLIENT_SECRET - - ORCID_CLIENT_ID - - ORCID_CLIENT_SECRET - - AUTH_SERVICE_NAME - - # --- Shared Postgres (two databases: kfauth + app) --- - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: kfauth - POSTGRES_PASSWORD: kfauth - POSTGRES_DB: kfauth - volumes: - - pgdata:/var/lib/postgresql/data - - ./selfhost/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U kfauth'] - interval: 5s - timeout: 5s - retries: 10 - - # --- MinIO (S3-compatible object storage) --- - # Remove this service if using external S3/R2 — set S3_* vars in app environment instead. - minio: - image: minio/minio:latest - depends_on: - withauth-init: - condition: service_completed_successfully - volumes: - - minio-data:/data - - withauth-config:/config:ro - entrypoint: /bin/sh - command: - - -c - - | - set -a && . /config/.env.minio && set +a - exec minio server /data --console-address ":9001" - healthcheck: - test: ['CMD', 'mc', 'ready', 'local'] - interval: 5s - timeout: 5s - retries: 10 - - # Create the default bucket on first run - minio-init: - image: minio/mc:latest - depends_on: - minio: - condition: service_healthy - withauth-init: - condition: service_completed_successfully - volumes: - - withauth-config:/config:ro - entrypoint: /bin/sh - command: - - -c - - | - set -a && . /config/.env.minio && set +a - mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" - # Two buckets by design: - # underlay — PRIVATE. Collection files; every read goes through the - # API, which access-checks and then hands out a - # short-lived presigned URL. Never make this public. - # underlaypublic — world-readable assets (org avatars) served directly. - mc mb --ignore-existing local/underlay - mc mb --ignore-existing local/underlaypublic - mc anonymous set download local/underlaypublic - echo "Buckets ready." - - # --- Auth server (kf-auth) --- - auth: - image: ghcr.io/knowledgefutures/kf-auth:latest - depends_on: - postgres: - condition: service_healthy - withauth-init: - condition: service_completed_successfully - environment: - NODE_ENV: production - PORT: 3000 - volumes: - - withauth-config:/config:ro - command: sh -c "set -a && . /config/.env.withauth && set +a && node dist/server.js" - - # --- Account server (kf-auth account UI) --- - account: - image: ghcr.io/knowledgefutures/kf-auth:latest - depends_on: - postgres: - condition: service_healthy - withauth-init: - condition: service_completed_successfully - environment: - NODE_ENV: production - PORT: 3001 - volumes: - - withauth-config:/config:ro - command: sh -c "set -a && . /config/.env.withauth && set +a && node dist/server-account.js" - - # --- Underlay app --- - app: - image: ghcr.io/knowledgefutures/underlay:latest - depends_on: - postgres: - condition: service_healthy - withauth-init: - condition: service_completed_successfully - auth: - condition: service_started - minio-init: - condition: service_completed_successfully - environment: - NODE_ENV: production - PORT: 4100 - APP_URL: ${DOMAIN:-http://localhost} - volumes: - - withauth-config:/config:ro - command: sh -c "set -a && . /config/.env.app && set +a && node --import tsx/esm server.ts" - - # --- Caddy reverse proxy --- - caddy: - image: caddy:2-alpine - ports: - - '80:80' - - '443:443' - volumes: - - ./selfhost/Caddyfile:/etc/caddy/Caddyfile:ro - - caddy-data:/data - - caddy-config:/config - environment: - DOMAIN: ${DOMAIN:-localhost} - APP_PORT: '4100' - depends_on: - - auth - - account - - app - -volumes: - pgdata: - minio-data: - withauth-config: - caddy-data: - caddy-config: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index d64f78b..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,187 +0,0 @@ -# Stack file — deploy with: docker stack deploy -c docker-compose.yml -# SOPS decrypts .env.{prod,dev}.enc → .env before this runs (see deploy workflow). - -services: - app: - image: ${IMAGE:-ghcr.io/knowledgefutures/underlay}:${IMAGE_TAG:-latest} - env_file: - - .env - environment: - NODE_ENV: production - # Push commits accumulate per-record structures in memory, so the heap has - # to scale with the largest collection pushed, not with steady-state - # traffic. 448 MB could not commit much past ~100k records. - # - # Measured: a 500k-record push costs ~900 MB of heap above baseline - # (~1.8 KB/record), so 2 GB carries a ~1M-record push with headroom. - # Raise APP_HEAP_MB (and APP_MEMORY_LIMIT with it) for a one-off larger - # ingest rather than making it the standing default — dev and prod share - # one 16 GB box, so the defaults here are paid four times over. - # - # Do not read this as "3.11M records will fit if we go high enough": at - # that size the push path needs the streaming commit, not a bigger number. - # See planning/local/demos/arxiv-ingest-measurements-v1.md. - NODE_OPTIONS: '--max-old-space-size=${APP_HEAP_MB:-2048}' - PORT: ${PORT:-3000} - UNDERLAY_MODE: ${UNDERLAY_MODE:-origin} - UNDERLAY_UPSTREAM: ${UNDERLAY_UPSTREAM:-} - UNDERLAY_NODE_NAME: ${UNDERLAY_NODE_NAME:-} - UNDERLAY_SYNC_SCHEDULE: ${UNDERLAY_SYNC_SCHEDULE:-0 0 * * 0} - ports: - - '${PORT:-3000}:${PORT:-3000}' - deploy: - replicas: ${APP_REPLICAS:-2} - resources: - limits: - # Must exceed APP_HEAP_MB — the V8 heap is only part of RSS. - # - # Sizing on the shared 16 GB box: dev + prod × 2 replicas = 4 app - # containers. They idle at a few hundred MB each and only one climbs - # at a time (a push is one request on one replica of one stack), so - # the realistic peak is 3× idle + 1× limit, not 4× limit. Reservations - # stay low deliberately — they are what Swarm schedules against, and - # over-reserving would strand memory the other stack needs. - memory: ${APP_MEMORY_LIMIT:-2560m} - cpus: '1.0' - reservations: - memory: 384m - cpus: '0.25' - update_config: - parallelism: 1 - delay: 10s - order: start-first - failure_action: rollback - rollback_config: - parallelism: 1 - order: start-first - restart_policy: - condition: on-failure - tmpfs: - - /tmp:size=64m - healthcheck: - test: - [ - 'CMD-SHELL', - 'node -e "Promise.all([fetch(\"http://127.0.0.1:${PORT:-3000}/api/health\"),fetch(\"http://127.0.0.1:${PORT:-3000}/\")]).then(rs=>{for(const r of rs)if(!r.ok)throw r.status}).catch(()=>process.exit(1))"', - ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 45s - logging: - driver: json-file - options: - max-size: '10m' - max-file: '3' - networks: - - appnet - - cron: - image: ${IMAGE:-ghcr.io/knowledgefutures/underlay}:${IMAGE_TAG:-latest} - env_file: - - .env - environment: - NODE_ENV: production - NODE_OPTIONS: '--max-old-space-size=128' - UNDERLAY_MODE: ${UNDERLAY_MODE:-origin} - UNDERLAY_UPSTREAM: ${UNDERLAY_UPSTREAM:-} - UNDERLAY_NODE_NAME: ${UNDERLAY_NODE_NAME:-} - UNDERLAY_SYNC_SCHEDULE: ${UNDERLAY_SYNC_SCHEDULE:-0 0 * * 0} - command: ['node', '--import', 'tsx/esm', 'tools/cron.ts'] - deploy: - replicas: 1 - resources: - limits: - memory: 192m - cpus: '0.5' - reservations: - memory: 64m - cpus: '0.1' - restart_policy: - condition: any - tmpfs: - - /tmp:size=16m - logging: - driver: json-file - options: - max-size: '5m' - max-file: '3' - networks: - - appnet - - postgres: - image: postgres:17-alpine - env_file: - - .env - environment: - POSTGRES_USER: ${POSTGRES_USER:-underlay} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-underlay} - POSTGRES_DB: ${POSTGRES_DB:-underlay} - command: > - -c shared_buffers=1GB - -c effective_cache_size=3GB - -c work_mem=32MB - -c maintenance_work_mem=256MB - -c max_connections=200 - -c max_wal_size=2GB - -c min_wal_size=256MB - -c wal_buffers=32MB - -c checkpoint_completion_target=0.9 - -c random_page_cost=1.1 - volumes: - - pgdata:/var/lib/postgresql/data - # Postgres allocates dynamic shared memory in /dev/shm for parallel query - # workers. Docker's 64 MB default is enough for small scans and not enough - # for a parallel aggregate over a few hundred thousand rows, which fails - # with "could not resize shared memory segment ... No space left on device". - # - # This must be the long-form tmpfs *volume*. `shm_size` is ignored by - # `docker stack deploy`, and the short `tmpfs:` list syntax documents only - # mode/uid/gid — not size. An earlier `tmpfs: - /dev/shm:size=1g` here was - # therefore silently a no-op, the 64 MB default stayed in place on dev, and - # a 300k-record commit died on it. - # - # 1 GiB, in bytes: the field takes a raw integer or a unit string. - - type: tmpfs - target: /dev/shm - tmpfs: - size: 1073741824 - ports: - - '${DB_PORT:-5432}:5432' - networks: - - appnet - - dbaccess - stop_grace_period: 60s - deploy: - replicas: 1 - resources: - limits: - memory: 4g - cpus: '2.0' - reservations: - memory: 1g - cpus: '0.5' - placement: - constraints: [node.role == manager] - restart_policy: - condition: any - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-underlay}'] - interval: 10s - timeout: 5s - retries: 5 - logging: - driver: json-file - options: - max-size: '10m' - max-file: '3' - -networks: - appnet: - driver: overlay - dbaccess: - driver: overlay - attachable: true - -volumes: - pgdata: diff --git a/docs/protocol-v2.md b/docs/protocol-v2.md new file mode 100644 index 0000000..2aa2dc8 --- /dev/null +++ b/docs/protocol-v2.md @@ -0,0 +1,1021 @@ +# Underlay protocol, version 2 + +**Status:** Stable. Frozen 2026-10-03. + +**Abstract.** This document specifies version 2 of the Underlay protocol: the canonical encoding +and hashing of records, schemas and files; the input rules a publisher's data must satisfy; the +construction of record and file trees; the version root and its hash; the repository layout in +which collections are stored; the signed version log; the pack format by which versions are +copied; and the HTTP interface by which servers serve and accept versions. + +**Change control.** A change to any rule or value that alters which inputs are accepted, how a +tree is built, or what is hashed requires a new protocol version (the `underlay` member of every +root, Section 10). Clarifications and additions that alter none of these are recorded in +[Appendix B](#appendix-b-revision-history). + +**Reference implementation.** `@underlay/protocol` (`packages/protocol` in the Underlay +repository). Test vectors: `packages/protocol/test/vectors/v2.json` +([Appendix A](#appendix-a-test-vectors)). Where this document and the reference implementation +disagree, this document is authoritative. + +## 1. Conventions and terminology + +### 1.1 Requirements language + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", +"RECOMMENDED", "NOT RECOMMENDED", "MAY" and "OPTIONAL" in this document are to be interpreted as +described in BCP 14 ([RFC 2119](https://www.rfc-editor.org/rfc/rfc2119), +[RFC 8174](https://www.rfc-editor.org/rfc/rfc8174)) when, and only when, they appear in all +capitals. + +Sections and paragraphs marked _informative_, and all examples and notes, are not normative. + +### 1.2 Terminology + +- **Collection**: a named sequence of versions, identified by a collection id. +- **Record**: a triple of an id, a type and a JSON value `data` (Section 4). +- **Type**: a named class of records, identified by a type slug and described by a schema. +- **Schema**: a JSON Schema document that the records of one type MUST satisfy (Section 5). +- **File**: a byte string, identified by its hash (Section 6). +- **Access set**: one of the two partitions of a version's content, `public` and `private` + (Section 9). +- **Tree**: a sorted set of entries partitioned into tree nodes by the rules of Section 8. +- **Tree node**: a leaf or interior node of a tree. +- **Version**: an immutable state of a collection, described by a root document (Section 10). +- **Repository**: the objects that represent one or more collections in a storage location + (Section 11). +- **Server**: an HTTP service that implements Section 11.3 and, if it accepts publications, + Section 11.4. Also called an _Underlay node_. +- **Client**: any party that reads from or publishes to a server. +- **Organization**: the party a collection belongs to, named by `owner` in its `collection.json` + (Section 11.1). +- **Owner**: a party permitted to read a collection's private set. Who is an owner is determined + by the server. + +### 1.3 Notation + +- **hash(x)** is the SHA-256 digest of the octet string `x`, written as 64 lowercase hexadecimal + characters. A string is hashed as its UTF-8 encoding. +- **JCS(v)** is the canonical JSON serialization of the value `v` (Section 2). +- **Key order** is the order defined in Section 7. +- `+` between strings denotes concatenation. +- Sizes are in octets (bytes). KiB and MiB are 2¹⁰ and 2²⁰ octets. +- Counts are non-negative integers not greater than 2⁵³ − 1. + +## 2. Canonical JSON + +Every JSON document that is hashed MUST be serialized as specified by +[RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) (JSON Canonicalization Scheme, JCS): + +- no insignificant whitespace; +- strings escaped as by ECMAScript `JSON.stringify`; +- numbers serialized as by ECMAScript `Number.prototype.toString`, with `-0` serialized as `0`; +- object members ordered by the UTF-16 code units of their keys. + +Note (informative): ECMAScript objects enumerate integer-like keys (`"9"`, `"10"`) first, in +numeric order, regardless of insertion order. Sorting keys into a new object and serializing it +with `JSON.stringify` therefore does not produce JCS. An implementation in ECMAScript must emit +object members as strings itself (reference: `packages/protocol/src/jcs.ts`). + +## 3. Input rules + +A client MUST apply these rules to every record line it publishes, and a server MUST apply them to +every record line it receives in a publication (Section 11.4). The rules apply to the whole line, +including members that are otherwise ignored. They are evaluated on the source text, because a +parsed value no longer carries the information they require. A server MUST also apply the syntax, +duplicate key, unsafe integer and lone surrogate rules to the schemas it receives; the depth rule +applies to record lines only. + +| Rule | A line or document is rejected if | Code | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| Syntax | it is not JSON as defined by RFC 8259, including an unterminated string or an invalid escape | `syntax` | +| Duplicate keys | an object has two members whose keys are equal after unescaping | `duplicate_key` | +| Unsafe integers | it contains an integer literal (no fraction, no exponent) whose magnitude exceeds 2⁵³ − 1 (`9007199254740991`). Literals with a fraction or exponent (`1e20`, `9007199254740993.0`) are accepted and take their IEEE 754 binary64 value | `unsafe_integer` | +| Lone surrogates | a string or key contains a UTF-16 surrogate code unit, literal or `\u`-escaped, that is not part of a surrogate pair | `lone_surrogate` | +| Depth | the line nests more than `MAX_JSON_DEPTH` + 1 (65) levels. Each object or array is one level and the envelope is level 1, so `data` and every other member may nest 64 levels | `too_deep` | +| Envelope | the line is not an object, has no `data` member, or has a `private` member that is not a boolean | `bad_envelope` | +| Record id | `id` is absent, not a string, empty, or longer than `MAX_ID_BYTES` (1,024) UTF-8 bytes | `bad_id` | +| Type slug | `type` is absent, not a string, empty, longer than `MAX_TYPE_BYTES` (128) UTF-8 bytes, begins with `.`, or contains `/`, `\`, U+0000–U+001F or U+007F | `bad_type` | +| Record size | the canonical form (Section 4) is longer than `MAX_RECORD_BYTES` (8,388,608) bytes | `record_too_large` | + +A record line that breaks more than one rule MUST be reported with the code of the first rule it +breaks in this order: + +1. the text is scanned from the start; the first of an unterminated string or invalid `\u` + escape (`syntax`), a duplicate key, an unsafe integer, a lone surrogate or an excess of depth + that occurs in the text determines the code, whether or not the text is otherwise JSON; + otherwise, a text that is not JSON is `syntax`; +2. `bad_envelope`, if the line is not an object; +3. `bad_id`; +4. `bad_type`; +5. `bad_envelope`, if `data` is absent or `private` is not a boolean; +6. `record_too_large`. + +The codes are part of the protocol. A server reports them (Section 11.4). + +Members of a record line other than `id`, `type`, `data` and `private` MUST be ignored. They are +not part of the record, its canonical form or its hash. + +Strings are not Unicode-normalized. `é` as U+00E9 and as U+0065 U+0301 are distinct ids with +distinct hashes. + +## 4. Records + +A record consists of an `id` (a string), a `type` (a type slug) and `data` (any JSON value). The +**canonical form** of a record is the string + +``` +'{"id":' + JCS(id) + ',"type":' + JCS(type) + ',"data":' + JCS(data) + '}' +``` + +The envelope members appear in the fixed order `id`, `type`, `data`; only `data` is canonicalized +under Section 2. + +- **Record hash** = hash(canonical form). +- **Record size** = the length of the canonical form in bytes. +- Within a version, a (type, id) pair MUST identify at most one record, across both access sets. + +**File references.** A record references a file through any object, at any depth of `data`, +whose `$file` member is a string consisting of `sha256:` followed by 64 lowercase hexadecimal +characters. The referenced file hash is the hexadecimal part. An object that is a file reference +is not searched for further references. An object whose `$file` member has any other value is not +a reference, and its members are searched as usual. + +## 5. Schemas + +A type's schema is a JSON object that is a JSON Schema draft-07 document (Section 5.1). +**Schema hash** = hash(JCS(schema)). + +A schema MUST be rejected if: + +- its root `private` member is present and is not a boolean; +- a schema that is the value of a member of a `properties` object, at any depth, has + `"private": true` (field-level privacy is not supported); +- its canonical form is longer than `MAX_SCHEMA_BYTES` (262,144) bytes; +- any `pattern` value or `patternProperties` key is longer than `MAX_PATTERN_LENGTH` (256) UTF-16 + code units. A `pattern` member inside `const`, `enum`, `default` or `examples` is data, not a + regular expression, and is not limited; +- the slug it is given under is not a valid type slug (Section 3). + +A root `"private": true` makes the type private (Section 9). + +### 5.1 Validation dialect + +**Acceptance of schemas.** A schema MUST be rejected unless: + +- its root `$schema` member, if present, is `http://json-schema.org/draft-07/schema` or + `http://json-schema.org/draft-07/schema#`; +- it is valid against the draft-07 meta-schema; +- every `pattern` value and `patternProperties` key compiles as an ECMAScript regular expression + with the `u` flag; +- every `$ref` resolves within the schema or to the draft-07 meta-schema. References are resolved + against the base URI `https://schema.underlay.invalid/` unless a `$id` establishes another. + +**Validation of records.** A record's `data` MUST be validated against its type's schema under +draft-07, with these refinements: + +- keywords adjacent to `$ref` are applied, as in draft 2019-09; +- keywords not defined by draft-07 are ignored, including later drafts' keywords + (`unevaluatedProperties`, `dependentRequired`, `prefixItems`, …) and draft-04's `id`. `$defs` + is honoured as a container of subschemas, and `$anchor` is honoured; +- `pattern` is an ECMAScript regular expression with the `u` flag; +- `multipleOf` m accepts x when the floating-point remainder r = x mod m satisfies + |r| < 1.1920929 × 10⁻⁷ or |m − r| < 1.1920929 × 10⁻⁷; +- string length is measured in Unicode code points; +- object members are the parsed document's own keys; names such as `__proto__` and `toString` + have no special meaning. + +**Formats.** `format` constrains strings only, and only for the names `date`, `time`, +`date-time`, `iso-time`, `iso-date-time`, `duration`, `uri`, `uri-reference`, `uri-template`, +`url`, `email`, `hostname`, `ipv4`, `ipv6`, `regex`, `uuid`, `json-pointer`, +`json-pointer-uri-fragment`, `relative-json-pointer` and `byte`. Each is defined as in +ajv-formats 3.0 in "full" mode. In particular, `date-time` and `time` require a time zone, +`date-time` accepts `T`, `t` or whitespace as the separator, and `email` requires a dot in the +domain. Other format names are ignored. + +Only the verdict (valid or invalid) is normative. Error messages, and the number of errors +reported, are not. + +Note (informative): the reference validator is `@cfworker/json-schema` with these refinements +(`packages/protocol/src/validate.ts`). Over all public production data it gives the same verdicts +as the AJV configuration of Underlay v1 (139 schemas, 330,300 records, 85,773 mutated records). + +## 6. Files + +**File hash** = hash(the file's bytes). A file's **size** is its length in bytes. + +## 7. Key order and boundary hash + +- **Key order.** Keys are compared lexicographically by their UTF-8 encodings, octet by octet; a + proper prefix precedes the longer key. This is Unicode code point order. Implementations MUST + NOT compare UTF-16 code units (ECMAScript's default `<` and `sort()`), which order differently + when one key has a character at or above U+10000 where the other has one in U+E000–U+FFFF. +- **Boundary hash.** u(k) is the first 8 bytes of hash(k), read as a big-endian unsigned 64-bit + integer. +- **tz(k)** is the number of trailing zero bits of u(k), from 0 to 64. + +## 8. Trees + +A tree is a set of entries with unique keys, in key order, partitioned into tree nodes. There are +two kinds: + +| Kind | Key | Entry | Entry size | +| ----------- | --------------- | ------------------------------ | ------------ | +| Record tree | record id | `[id, recordHash, recordSize]` | `recordSize` | +| File tree | file hash (hex) | `[fileHash, fileSize]` | `fileSize` | + +### 8.1 Shape + +| Parameter | Value | +| ----------------------------- | ----- | +| `LEAF_BOUNDARY_BITS` | 10 | +| `INTERIOR_BOUNDARY_BITS_STEP` | 6 | +| `LEAF_MAX_ENTRIES` | 8,192 | +| `INTERIOR_MAX_CHILDREN` | 1,024 | + +- **Leaves (level 0).** The entries are taken in key order. The current leaf ends after entry + `k` if tz(k) ≥ 10, if the leaf holds `LEAF_MAX_ENTRIES` entries, or if `k` is the last entry. +- **Interior level i, i ≥ 1.** The nodes of level i − 1 are taken in order. The current level-i + node ends after child `c` if 10 + 6i ≤ 64 and tz(last key of `c`) ≥ 10 + 6i, if the node has + `INTERIOR_MAX_CHILDREN` children, or if `c` is the last node of level i − 1. +- **Root.** Levels are built in ascending order. The root is the single node of the lowest level + that has exactly one node. If the tree has one leaf, that leaf is the root. An empty tree has no + nodes, and its root is `null`. + +Consequences (informative): the same entry set yields the same tree regardless of construction +order; a natural boundary (tz(k) ≥ 10) depends only on its key, and a forced split only on the +position since the preceding boundary; any range between two natural boundaries can therefore be +rebuilt independently. The mean leaf holds 1,024 entries and the mean interior fan-out is 64. + +### 8.2 Node encoding + +``` +leaf: {"e":[entry, ...],"t":"leaf"} +interior: {"e":[[lastKey, childHash, count, bytes], ...],"l":level,"t":"node"} +``` + +- A node is encoded as JCS, which places members in the order shown. +- `lastKey` is the last key under the child, `childHash` the child's node hash, `count` the number + of entries under the child, and `bytes` the sum of their entry sizes. `level` is the node's + level. +- **Node hash** = hash(encoded node). + +### 8.3 Validity + +A tree is valid if and only if building its entries under Section 8.1 yields the same root hash. + +A receiver of tree nodes from another party (Section 11.2) MUST reject a node unless: + +- its bytes hash to the expected node hash and are its canonical encoding; +- its keys are strictly increasing, within the node and across the tree; +- each interior entry's `lastKey`, `count` and `bytes` equal those of the child it names, and + `count` ≥ 1; +- each child is exactly one level below its parent; +- in a record tree, each key is a valid record id (Section 3). + +A receiver MUST reject a record leaf unless each of its entries matches the corresponding line of +the leaf's body (Section 11), with an out-of-line pointer resolved to its record: the line hashes +to the entry's record hash, is exactly the entry's size in bytes, and is the canonical form of a +record whose `id` is the entry's key and whose `type` is the type of the tree. + +A node that hashes correctly but violates a structural rule is invalid; accepting it would admit +two roots for one entry set. + +Record documents (members `id`, `type`, `data`) and node documents (members `e`, `l`, `t`) have +disjoint member names, so neither can be interpreted as the other. + +## 9. Access sets + +Each version has two access sets, `public` and `private`. + +- **Records.** A record published with `"private": true`, and every record of a private type, + belongs to the private set. Every other record belongs to the public set. +- **Types.** Each set lists, for each type it contains, the type's schema hash and the root of the + tree of that set's records of the type. + - A private type appears in the private set only. + - A public type appears in the public set, including when it has no public records (with a + `null` root). It also appears in the private set if it has private records. +- **Files.** A file belongs to each set that contains a record referencing it. A file declared in + a publication (`files.add`, Section 11.4) also belongs to the private set, whether or not records + reference it, and remains declared in later versions until a publication removes the declaration + (`files.remove`). +- **Readers.** Owners MAY read both sets. Any other reader MAY read the public set only, and only + of a collection whose visibility is `"public"`. Visibility is collection state outside the + version, recorded in `collection.json` (Section 11.1); changing it changes no version. + +## 10. Versions + +``` +SetObject = { + "types": { slug: { "schema": schemaHash, "root": treeHash | null, "count": n, "bytes": b }, ... }, + "files": { "root": treeHash | null, "count": n, "bytes": b } +} +PrivateSetObject = SetObject + { "salt": 64 hex characters } +root = { "underlay": 2, "metadata": object | null, "public": SetObject, "private": commitment | null } +``` + +- `count` and `bytes` are the tree root's totals, or 0 for a `null` root. +- **Commitment** = hash(JCS(PrivateSetObject)). +- The salt is 32 random bytes, encoded as hexadecimal. A writer MUST choose it once per collection + and MUST reuse it for every version of that collection, so that an unchanged private set keeps + its commitment. +- `private` is `null` if and only if the private set is empty: it lists no types and no files. +- **Version hash** = `"ulv2:"` + hash(JCS(root)). + +A version hash commits to content only. A root has no parent pointer; identical content yields the +same version hash in any collection, except where a private set is present, since salts differ +between collections. Lineage, semver, messages and authorship are recorded in the version log +(Section 11.1). + +A reader of the public set can verify the version hash and every object of the public set, and +learns of the private set only whether it exists. An owner additionally obtains the +PrivateSetObject, including its salt, and can verify it against the commitment. + +### 10.1 Semver + +Each version of a collection has a semver of the form `v..`. The server that +commits a version MUST assign it from the differences between the version and its base +(Section 11.4), with M, m and p the base's major, minor and patch: + +1. The first version of a collection is `v1.0.0`. +2. If a type was added or removed, or a type's schema hash changed, the semver is `v(M+1).0.0`. + Making a type private or public changes its schema and is therefore covered by this rule. + When a type's schema changes, every record of the type carried over from the base MUST be + validated against the new schema, and the publication MUST be refused if any fails. +3. Otherwise, if any record was added, removed or changed in either set, the semver is + `vM.(m+1).0`. A record moving between sets is a change. +4. Otherwise (only the metadata or the file sets changed), the semver is `vM.m.(p+1)`. + +A publication whose version hash equals its base's version hash MUST NOT create a version. + +Semvers are unique within a collection and strictly increasing. A version converted from Underlay +v1 retains the semver it had in v1. + +## 11. Repository layout + +A repository is the representation of collections in a storage location (an object store such +as an S3-compatible bucket). The layout is identical in a server's own storage and in a mirror. +Keys are relative to the location's prefix. + +``` +nodes/ encoded node (Section 8.2), gzip +bodies/.ndjson.gz the body of a record leaf +records/.json.gz an out-of-line record, gzip +schemas/.json JCS(schema) +roots/.json JCS(root); is the version hash without "ulv2:" +private/.json JCS(PrivateSetObject); only where private sets are held +files/ file bytes +collections//collection.json collection description (Section 11.1) +collections//log/.json version log entry +collections//head.json log head +``` + +- **Bodies.** The body of a record leaf contains one line per entry of the leaf, in entry order, + each terminated by `\n`. A line is either the canonical form of the entry's record or an + out-of-line pointer `{"$ref":""}`, in which case the record is stored at + `records/.json.gz`. A body is one or more concatenated gzip members (RFC 1952 + §2.2); readers MUST accept any number of members. Which records are stored out of line, and + where members divide, are the writer's choice. +- **Immutability.** Every object outside `collections/` is content-addressed and MUST NOT change + once written. A reader that does not trust a location MUST verify each object before use: nodes + against their hash, body lines against the leaf's entries, roots against the version hash, and + schemas and PrivateSetObjects against their hashes. +- **Write order.** A writer MUST write every object a version reaches (leaves and their bodies, + then interior nodes, then the PrivateSetObject and root) before the version's log entry, and the + log entry before `head.json`. A reader that finds `head.json` can therefore read every object + it reaches. +- **Self-containment.** No object in a location refers to another location. +- Readers MUST ignore keys outside this layout. + +Note (informative): with no out-of-line pointers, the concatenation of a type's bodies in tree +order is that type's records as gzip-compressed NDJSON. Data internal to a server (push sessions, +staged uploads, indexes) is not part of a repository. The reference server's location check +writes `.underlay/check.json` under the prefix. + +### 11.1 Version log + +Each collection has one log entry per version: + +``` +entry = {"actorId","appId","baseSemver","collectionId","createdAt","keyId","message","prev","semver","seq","sig","versionHash"} +``` + +- `collectionId` is the id of the collection whose log contains the entry. It is signed, so that + an entry or a log cannot be presented as another collection's. +- `seq` is the version's position in the log, starting at 1. `semver` and `versionHash` identify + the version; `baseSemver` is its base's semver. +- `createdAt` is an ISO 8601 timestamp in UTC. +- `appId`, `actorId`, `baseSemver` and `message` MAY be `null`. Writers SHOULD write `actorId` as + `null`, since a log is as public as its collection; the member is retained so that existing + entries verify. +- `keyId` is the first 16 hexadecimal characters of hash(raw public key) of the signing key. +- `sig` is the Ed25519 signature over the UTF-8 encoding of JCS(entry without `sig`), encoded as + base64url without padding. +- **Entry hash** = hash(JCS(entry)), with `sig` included. +- `prev` is the entry hash of entry `seq − 1`, or `null` when `seq` is 1. +- `head.json` is JCS(`{"entryHash","seq","versionHash"}`) of the latest entry. It is overwritten + after each new entry is written. +- `collection.json` describes the collection (below). + +**Collection description.** `collection.json` is a JSON object: + +``` +{"id", "owner": {"id", "did", "handle", "name"}, "slug", "name", "description", "visibility", "ark", "keys"} +``` + +| Member | Value | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | The collection id | +| `owner` | The organization that owns the collection. `id` is its identifier at the server that wrote the file; `did` its DID, or `null`; `handle` its handle (Section 11.3.1), or `null`; `name` its display name | +| `slug` | The collection's slug, unique among its owner's collections | +| `name` | The collection's display name | +| `description` | A string, or `null` | +| `visibility` | `"public"` or `"private"`. A private collection's versions, public sets included, are readable by owners only (Section 9) | +| `ark` | The collection's ARK (`ark:/`), or `null` | +| `keys` | An array of `{"id", "alg": "Ed25519", "publicKey"}`, where `publicKey` is the raw public key in base64url: every key that has signed an entry of the log | + +- It is neither hashed nor signed. Readers MUST parse it as JSON, MUST NOT depend on its + serialization, and MUST ignore members they do not recognize. +- A writer MUST write it before the collection's first log entry, and MUST rewrite it whenever a + member changes, whether or not a version is published. A copy of a repository (a mirror) + SHOULD carry each rewrite. The file is how a repository records, without a server, who owns a + collection and whether it is public. + +A verifier MUST use a key only under the id derived from it: a key listed under any other id MUST +be ignored. + +A log is valid if and only if every entry from 1 to `head.seq` is present; every entry's +`collectionId` names the collection being read; every `prev` equals the entry hash of the +preceding entry; every signature verifies under a trusted key; and `head.entryHash` and +`head.versionHash` equal the last entry's hash and `versionHash`. + +Which keys a verifier trusts is not specified by this version of the protocol (Section 13). + +### 11.2 Packs + +A version is transferred between repositories as a **pack**: the repository objects the version +reaches that the receiver's base version does not, each under its repository key. A pack is an +uncompressed POSIX tar archive, with PAX extended headers for names longer than 100 bytes. Objects +are carried as stored. File bytes and `collections/` objects are not carried in packs. + +A pack contains, in this order: + +1. the schemas the base does not have; +2. for each set sent, public first: for each of the set's record trees, the tree nodes not present + at the same position in the base's tree of that type (in the same set, otherwise in the other + set), parents before children, and after each new leaf the out-of-line records its body points + to, then its body; then the new tree nodes of the set's file tree, in the same way; +3. the PrivateSetObject, if the private set is sent; +4. the root. + +A receiver MUST NOT depend on any order except that a leaf precedes its body, out-of-line records +precede the body that points to them, and the root is last. + +A receiver MUST, before writing an object, verify it against its key: tree nodes and out-of-line +records by hash; bodies line by line against their leaf's entries (Section 8.3), the leaf having +arrived first; and schemas, the PrivateSetObject and the root by hash and canonical form. It MUST +reject a root that does not have exactly the members of Section 10, with `underlay` equal to 2, +`metadata` an object or `null`, valid type slugs, and set totals of 0 for `null` roots, and a +PrivateSetObject that is empty or lacks a 64-hex `salt`. It MUST then, for every tree of every set received, merge the entry changes from its base tree into that +base tree under Section 8.1 and obtain exactly the received root, count and bytes, and MUST hold a +body for every new record leaf. It MUST write the root only after all checks pass, and MUST refuse +a pack that fails any check. + +### 11.3 Serving over HTTP + +A server serves each collection under a **collection URL**: an absolute URL without a trailing +slash, relative to which the paths below are resolved. The form of collection URLs is the +server's choice; Section 11.3.1 gives the RECOMMENDED form. + +| Route | Serves | Section | Required | +| --------------------------------------------------- | ----------------------------------- | ------- | -------- | +| `GET ` | the collection | 11.3.3 | MUST | +| `GET /versions` | its versions, newest first | 11.3.3 | MUST | +| `GET /versions/` | one version | 11.3.3 | MUST | +| `GET /versions//records` | a page of records | 11.3.4 | MUST | +| `GET /versions//records//` | one record | 11.3.4 | MUST | +| `GET /versions//records.ndjson` | every record, streamed | 11.3.4 | MUST | +| `GET /versions//records.ndjson.gz` | the public set's records, as stored | 11.3.4 | SHOULD | +| `GET /records///history` | one record across versions | 11.3.4 | SHOULD | +| `GET /schemas` | a version's type set | 11.3.5 | MUST | +| `GET /versions//diff` | the records two versions differ by | 11.3.5 | MUST | +| `GET /versions//files` | a version's files | 11.3.5 | MUST | +| `GET`, `HEAD /files/` | one file's bytes | 11.3.5 | MUST | +| `GET /export` | a version as a tar archive | 11.3.5 | SHOULD | +| `GET /log` | the version log | 11.3.6 | MUST | +| `GET /versions//pack` | a version's objects | 11.3.6 | MUST | +| `GET /versions//manifest` | a version's record ids and hashes | 11.3.6 | MUST | + +Publishing (Section 11.4) adds routes under the same collection URL. + +#### 11.3.1 Collection URLs + +The RECOMMENDED form of a collection URL is `//`, where `` is the +server's API base URL, `` the collection's slug and `` one of: + +- a **hosted handle**: an organization's slug at the server, matching + `^[a-z0-9]+(-[a-z0-9]+)*$`; +- a **domain handle**: a domain name the organization has shown it controls, such as + `press.mit.edu`; +- a **DID**: the organization's DID, beginning `did:`. + +A slug contains neither `.` nor `:`, so the three forms are distinguished by their text. The +**collection id form** `/_/` names a collection by its id; `_` is not a +handle. + +- A server that offers this form MUST resolve each owner form it supports, and the id form, to + the same collection, and MUST serve it at each URL rather than redirect. An owner form the + server does not support is a 404. +- Every response from a route of this section or Section 11.4 for a collection the caller may + read MUST carry the header `x-underlay-collection` with the collection id. +- A handle can change, and a handle given up can later name another organization. A client that + keeps a reference to a collection SHOULD keep the id form, or the DID form where the owner has + a DID, and not a handle. + +Informative: underlay.org's `` is `https://underlay.org/api/collections`, and its hosted +handles are its organization slugs. It does not yet resolve DIDs or domain handles. + +#### 11.3.2 Conventions + +- **Version selectors.** `` is a semver (the leading `v` is optional), a version hash + (`ulv2:`), or `latest`. A version hash that names several versions of the collection (a + reverted change repeats a hash) selects the latest of them. Wherever a query parameter names a + version (`base`, `from`, `since`, `version`), it takes the same forms and MUST name a version of + the same collection. +- **Responses** are JSON (`application/json`) unless stated otherwise. Timestamps are ISO 8601 + strings in UTC. A server MAY add members to any object in a response; a client MUST ignore + members it does not recognize. The members listed in this section are the standard ones. +- **Records** in responses are Record objects: + + ``` + Record = {"id", "type", "data", "hash", "private"?} + ``` + + `hash` is the record hash (Section 4). `"private": true` marks a record of the private set, and + appears only in responses to owners. + +- **Pagination.** A paged response carries `pagination: {"limit", "hasMore", "nextCursor"}`. While + `hasMore` is true, a client obtains the next page by repeating the request with `cursor` set to + `nextCursor`. Cursors are opaque. A server MAY cap `limit` and MAY return fewer items than + requested. +- **Access.** A caller who is an owner reads both sets of every version. Any other caller reads + the public sets of a collection whose visibility is `"public"`, and nothing of a private one. + Counts and totals in a response are of what the caller may read. +- **Withheld content.** A server MAY withhold a record or file for legal reasons. Counts and + totals MAY still include withheld records. + +**Errors.** Error responses carry a JSON body `{"error": }`. + +- 404: the collection, version, base, record or file does not exist or the caller may not read + it. A server MUST NOT distinguish these cases. +- 403: `sets=all` was requested by a caller who may read the public set but not the private set. +- 400: a malformed parameter, such as a `sets` that is neither `public` nor `all`. +- 451: the server may not serve a file the caller could otherwise read, for legal reasons. A + server MUST answer 404 rather than 451 for a file the caller may not read. +- Other statuses carry their HTTP meanings, including 401 for invalid credentials and 429, with + `Retry-After`, for rate limiting. + +**Authentication** is the server's choice. A server without access control MUST serve public sets +only and MUST answer `sets=all` with 403. + +#### 11.3.3 Collections and versions + +**Collection.** `GET ` returns: + +``` +{"id", "owner", "slug", "name", "description", "visibility", "ark", "createdAt", "updatedAt", + "versionCount", "head"} +``` + +- `id`, `owner`, `slug`, `name`, `description` and `visibility` are as in `collection.json` + (Section 11.1). +- `ark` is the collection's ARK as a URL that resolves it, ending in `ark:/`, or + `null`. +- `head` is `{"semver", "hash"}` of the latest version, or `null` before the first. + +**Version summary.** + +``` +VersionSummary = {"semver", "hash", "baseSemver", "message", "appId", "createdAt", + "recordCount", "fileCount", "totalBytes", "typeCounts", "ark"} +``` + +- `semver`, `hash`, `baseSemver`, `message` and `appId` are as in the version's log entry + (Section 11.1); `createdAt` is when it was published. +- `recordCount`, `fileCount` and `totalBytes` total the sets the caller may read; `totalBytes` + is the sum of their record and file sizes. `typeCounts` maps each type slug to its number of + records in those sets. +- `ark` is the version's ARK as a URL that resolves it, or `null`. + +**Versions.** `GET /versions?limit=&offset=` returns a JSON array of +VersionSummary, newest first, at most `limit` of them after skipping `offset`. + +**Version.** `GET /versions/` returns the VersionSummary with two more members: +`metadata`, the root's metadata (Section 10); and `schemas`, mapping each type slug the caller +may read to its schema. + +#### 11.3.4 Records + +Records are ordered by type slug, then by id, each in key order (Section 7). + +**Records.** `GET /versions//records?type=&limit=&cursor=` returns a +page: + +``` +{"records": [Record, ...], "pagination": {"limit", "hasMore", "nextCursor", "total"}} +``` + +- With `type`, the page holds that type's records; without it, every type's. +- `total` is the number of records the request selects, across all pages. +- `offset=`, without a cursor, skips the first n records. + +**Record.** `GET /versions//records//` returns the Record with +`semver`, the version's semver. `` and `` are percent-encoded path segments. + +**Records as NDJSON.** `GET /versions//records.ndjson` streams every record the +caller may read, one Record per line, with content type `application/x-ndjson`. + +- `type=` limits the stream to one type. +- `after_type=&after=` resumes after that record, through the types that follow; + `type=&after=` resumes within the type. `after` without `after_type` or `type` is a 400. +- The header `x-underlay-record-count` gives the number of lines. + +**Public records as stored.** `GET /versions//records.ndjson.gz?type=` returns +the public set's records, or one type's, as gzip (`application/gzip`). Each line is the +canonical form of a record (Section 4), without `hash`. The body MAY consist of several gzip +members (RFC 1952 §2.2), so that a server can send stored leaf bodies unchanged. With `type`, a +type with no public records is a 404. + +**History.** `GET /records///history` returns the versions in which a record +was added, changed or removed, oldest first, as the caller may read them: + +``` +{"type", "id", "changes": [{"seq", "semver", "createdAt", "change", "hash"}, ...], "truncated"} +``` + +- `change` is `"added"`, `"updated"` or `"removed"`; `hash` is the record hash after the change, + or `null` for a removal. +- A server MAY consider only its latest versions; `truncated` is then true. +- A record never present is a 404. + +#### 11.3.5 Schemas, differences and files + +**Schemas.** `GET /schemas?version=&raw=` returns the type set of a version +(default `latest`): + +``` +{"semver", "schemas": [{"slug", "schemaHash", "schema"}, ...]} +``` + +A server MAY annotate `schema` with members whose names begin with `x-`. With `raw=true` it MUST +NOT: `schema` is then the schema as published, and JCS(`schema`) hashes to `schemaHash`. + +**Differences.** `GET /versions//diff?from=&limit=&cursor=` returns the +records that differ between version `from` and version ``, one page at a time: + +``` +{"from", "to", "added": [{"id", "type", "data"}, ...], "updated": [{"id", "type", "data"}, ...], + "removed": [{"id", "type"}, ...], "pagination": {"limit", "hasMore", "nextCursor"}, + "meta": {"schemaChanged", "metadataChanged", "filesAdded", "filesRemoved"}} +``` + +- `from` defaults to the version before ``. For the first version it is `null`, and every + record is added. +- `added` and `updated` carry the records' data in ``. +- To an owner, a record that moved between sets with an unchanged hash has not changed. To a + reader of the public set, a record that left it is removed, and one that joined it is added. +- `meta` compares the versions' type sets and metadata. `filesAdded` and `filesRemoved` count the + files the caller may read that were added and removed; they are given on the first page and are + 0 on later pages. + +**File list.** `GET /versions//files` returns a JSON array of the version's files +that the caller may read, in key order: + +``` +[{"hash", "size", "mimeType", "referenceCount"}, ...] +``` + +`referenceCount` is the number of records the caller may read that reference the file. A server +MAY cap the length of the list; the manifest (Section 11.3.6) pages through every file. + +**Files.** `GET /files/` returns the file's bytes, or a redirect to them. A +`HEAD` response carries `content-length`. `` is 64 hexadecimal characters, optionally +prefixed with `sha256:`. A server MUST serve a file only to a caller who may read a set that holds +it (Section 9). + +**Export.** `GET /export?version=&format=tar|tar.gz` returns a version (default +`latest`) as a tar archive, gzipped by default, of what the caller may read: + +``` +manifest.json {"collection", "version", "schemas", "files_missing", "files_withheld"} +README.md the version's metadata.readme, when it has one +records/.ndjson one Record per line +files/ file bytes +``` + +`files_missing` lists files the server does not hold, and `files_withheld` files it may not serve. + +#### 11.3.6 Replication + +These routes let a client or another server copy a collection and verify it (Section 11.3.7). + +**Log.** `GET /log?after=&limit=` returns `{"collection", "head", "entries"}`: + +- `collection` is the collection's `collection.json` (Section 11.1) and `head` its `head.json`; + either MAY be `null` before the first version; +- `entries` are the log entries with `seq` greater than `after` (default 0), in ascending `seq` + order, at most `limit` of them. A server MAY cap `limit` and MAY return fewer entries than + requested. A client obtains the remainder by repeating the request with `after` set to the last + `seq` received, until it reaches `head.seq`. + +**Pack.** `GET /versions//pack?base=&sets=public|all` returns the pack (Section +11.2) of version `` against `base`, with content type `application/x-tar`. Without `base`, the +pack holds every object the version reaches. `sets` defaults to `public`; `all` includes the +private set. The response carries the headers `x-underlay-version` (the version hash), +`x-underlay-base` (the base's version hash, or empty) and `x-underlay-sets` (the sets sent). + +**Manifest.** `GET /versions//manifest?cursor=&limit=` returns the version's +records as the caller may read them, without bodies: + +``` +{"semver", "hash", "schemas": {slug: schemaHash}, "records": [{"id", "type", "hash", "private"?}, ...], + "files": [fileHash, ...], "pagination": {"limit", "hasMore", "nextCursor"}} +``` + +- Records are in order of type, then id. A record of the private set carries `"private": true`. + A caller who cannot read the private set receives the public set only. +- `files` lists the hashes of the files the caller may read, on the first page only (an empty + array on later pages). A server MAY cap the list; `filesTruncated: true` then says so. +- `since=` returns the changes since that version instead of `records`: + `delta: {"added", "updated", "removed"}`, each an array of `{"id", "type", "hash"}`, an update + with `previousHash`, and `since`, that version's semver. To an owner, each carries `"private": +true` for the private set, and a move between sets is an update, with `previousPrivate`. + +#### 11.3.7 Verification + +A client MUST NOT trust a server's responses: it verifies the log under Section 11.1, receives +packs under Section 11.2, and verifies file bytes against their hash. A copy obtained from any +server, including a mirror operated by a third party, then carries the same guarantees as one +obtained from the origin. The other routes are not verifiable on their own; a client that requires +proof of content reads packs, or checks each Record's `hash` against a verified tree. + +### 11.4 Publishing + +A client publishes a version by **delta push**: it opens a session against a base version, +uploads the records it adds or changes and the ids it deletes, and commits. The server builds the +trees, writes and signs the log entry, and assigns the semver (Section 10.1). Delta push is the +only publication mechanism; a server MUST NOT accept tree nodes or packs from clients. + +``` +POST /push open a session +POST /push//records upload records (NDJSON) +POST /push//deletes upload deletes (NDJSON) +PUT /files/ upload a file +POST /push//commit commit +GET /push/ session status +DELETE /push/ abandon the session +``` + +**Opening a session.** The request body is a JSON object. Every member is OPTIONAL. + +| Member | Meaning | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `base` | The semver of the version the changes are against. If it is not the collection's head, the server MUST answer 409 with `currentVersion`. `null` or absent: the head at the time of opening | +| `schemas` | The complete type set, slug → schema (Section 5). It replaces the base's type set; a type omitted is removed with its records. Absent: the base's type set | +| `metadata` | Replaces the base's metadata. An object or `null` | +| `metadata_patch` | An object whose top-level members are merged into the base's metadata. Ignored if `metadata` is present. With neither, the base's metadata is kept | +| `files` | `{"add": [fileHash, …], "remove": [fileHash, …]}`: files to declare or remove in addition to those records reference (Section 9) | +| `message` | A string recorded in the log entry | +| `app_id`, `actor_id` | Strings identifying the publishing application and actor | +| `strip_unknown_fields` | A boolean; see **Records** | + +The response is 200 with `{"session_id", "base", "needed_files", "expires_at", "limits"}`: + +- `base` is the semver the session is against, or `null` if the collection has no versions; +- `needed_files` lists the declared files the server does not hold for the collection; the client + MUST upload them before committing; +- `expires_at` is when the session expires; it is extended by each records or deletes request; +- `limits` are the server's limits, by which a client MUST size its requests: + +| Limit | Meaning | +| ---------------------- | ----------------------------------------------------------------- | +| `open_bytes` | Maximum body of the open request, in bytes | +| `batch_bytes` | Maximum body of a records or deletes request, in bytes | +| `batch_lines` | Maximum lines in a records or deletes request | +| `session_idle_seconds` | Time without an upload after which an open session expires | +| `open_sessions` | Maximum sessions one user may have open or committing at once | +| `file_bytes` | Maximum file size accepted by `PUT /files/` | + +**Records.** `POST …/records` takes NDJSON record lines `{"id", "type", "data", "private"?}` and +answers 200 with `{"received": n}`. Each line MUST satisfy the input rules (Section 3) and its +type's schema (Section 5.1), and its type MUST be in the session's type set. + +- If `data` is an object with top-level members not listed in its schema's root `properties`, the + record MUST be refused, unless the session set `strip_unknown_fields`, in which case those + members are removed before the record is hashed. A schema without root `properties` admits any + members. +- `"private": true` places the record in the private set (Section 9). +- If any line fails, the server MUST answer 422 with `validationErrors`, one per failing line + identified by its 1-based `line` number among the request's non-empty lines, and MUST store + nothing from the request. A server MAY list only the first failures, with `totalErrors` giving + their number. + +**Deletes.** `POST …/deletes` takes NDJSON lines `{"type", "id"}` and answers 200 with +`{"received": n}`. Each line MUST satisfy the syntax, duplicate key, unsafe integer and lone surrogate rules, and its `id` and `type` the record id and type slug rules (Section 3); the type MUST be in the session's type set. Deleting a (type, id) the base does +not hold is not an error. + +A records or deletes request with no lines is answered with 400. + +Within a session, the later upload of a (type, id) supersedes an earlier one, whether either is a +record or a delete. Uploads MAY be repeated and divided across any number of requests. + +**Files.** `PUT /files/` with the file's bytes stores the file for the +collection. The server answers 201, or 400 if the bytes do not hash to ``. A server MAY +offer other upload mechanisms for larger files. Every file referenced by a new record, and every +declared file, MUST be held for the collection when the session is committed. A file is held for a +collection if it is in a file tree of the base version, if it was in the public set of any earlier +version of the collection, or if its bytes were uploaded to the collection. A file held only for +another collection is not held. A server MUST hash the bytes of every upload, including when it +already stores the file. + +**Commit.** `POST …/commit` builds the version. + +- 201 with `{"semver", "hash", "recordCount", "fileCount", "changes": {"added", "removed", +"updated"}}` when the commit completes within the request. +- 202 with `{"session_id", "status": "committing"}` when the commit runs in the background. A + client MAY request this with `?async=true`; a server MAY choose it for any commit. The client + polls `GET …/push/` until `status` is `committed`, when `result` holds the 201 body, or + `failed`, when `error` holds the failure body. +- A commit refused because files are not held (422 with `filesNeeded`) leaves the session open: + the client uploads the files and commits the same session again. A polling client sees `status` + `open`, with the refusal in `error`. +- Committing a session that has already committed answers 201 with the same body. +- A client that holds the base SHOULD compute the new version's hash itself and compare it with + `hash`. + +**Clients without a copy** (informative). A client that keeps no copy of the collection reads the +base's manifest (Section 11.3.6), compares each of its records with the manifest by (type, id), +hash and set, uploads the records that are new, changed or moved between sets, and deletes the +(type, id) pairs it no longer has. The upload is then proportional to the changes. + +**Errors.** Errors carry a JSON body `{"error": }`. Authentication and 404 follow +Section 11.3.2. + +- 403: the caller may read the collection but not publish to it, or the session belongs to + another user. +- 400: a malformed body, a `metadata` that is neither an object nor `null`, a `metadata_patch` that is not an object, or a request with no + lines. +- 409: `base` is not the head (with `currentVersion`); the head changed before the commit; the + session is not open; or the publication changes nothing (with the head's `hash`). +- 413: a body exceeds `open_bytes` or `batch_bytes`, a request exceeds `batch_lines`, or a file + exceeds `file_bytes`. +- 422: records or deletes fail; a schema is refused; records carried over from the base fail a + changed schema (`validationErrors`); or the commit lacks files (`filesNeeded`). +- 429: the user has `open_sessions` sessions open or committing, or a rate limit applies (with + `Retry-After`). + +## 12. Limits and constants + +| Constant | Value | Section | +| ----------------------------- | ------------------------------- | ------- | +| `PROTOCOL_VERSION` | 2 | 10 | +| `VERSION_HASH_PREFIX` | `ulv2:` | 10 | +| `LEAF_BOUNDARY_BITS` | 10 | 8.1 | +| `INTERIOR_BOUNDARY_BITS_STEP` | 6 | 8.1 | +| `LEAF_MAX_ENTRIES` | 8,192 | 8.1 | +| `INTERIOR_MAX_CHILDREN` | 1,024 | 8.1 | +| `MAX_SAFE_INTEGER_LITERAL` | 9,007,199,254,740,991 (2⁵³ − 1) | 3 | +| `MAX_JSON_DEPTH` | 64 | 3 | +| `MAX_RECORD_BYTES` | 8,388,608 (8 MiB) | 3 | +| `MAX_ID_BYTES` | 1,024 | 3 | +| `MAX_TYPE_BYTES` | 128 | 3 | +| `MAX_SCHEMA_BYTES` | 262,144 (256 KiB) | 5 | +| `MAX_PATTERN_LENGTH` | 256 (UTF-16 code units) | 5 | + +The reference implementation defines these in `packages/protocol/src/constants.ts`; the +test vectors repeat them. + +Server limits (Section 11.4) are not protocol constants. Informative: underlay.org advertises +`open_bytes` 8 MiB, `batch_bytes` 16 MiB, `batch_lines` 10,000, `session_idle_seconds` 3,600, +`open_sessions` 20 and `file_bytes` 32 MiB; caps log pages at 1,000 entries and manifest pages at +25,000 records; stores records over 64 KiB out of line; and commits in the background when a +session uploaded more than 100,000 records or a schema change revalidates more than 100,000. + +## 13. Security considerations + +- **Existence.** A server answers 404, not 403, for content a caller may not read (Section + 11.3.2), so that a response does not confirm the content exists. +- **Content by hash.** A server MUST NOT serve a tree node or body by hash alone, and MUST serve a + record, schema or file located by hash only where it occurs in a set the caller may read. + Otherwise a small private object with guessable content could be confirmed by computing its + hash. +- **Presence during publication.** A server MUST NOT treat content it holds for other collections + as present in a session. Records are always uploaded in full, and a file counts only if it is + held for the collection (Section 11.4). This prevents a publisher from confirming, or binding + into its own collection, content it does not possess. +- **Private set commitment.** The salt prevents confirmation of guessed private content from the + commitment. Because `private` is `null` exactly when the private set is empty, the root reveals + whether a private set exists. +- **Split views.** A version hash proves a version's content, not that a server shows every reader + the same versions. The hash-chained, signed version log (Section 11.1) makes omission and + reordering detectable by readers who compare log heads. +- **Signing key trust (open issue).** This version of the protocol does not specify how a verifier + obtains trusted signing keys. A verifier that trusts the keys listed in a `collection.json` + served by an untrusted server can be presented with a forged history on first contact. The + reference client trusts the keys of the `collection.json` it is served and anchors on the last + entry it has verified. +- **Visibility in copies.** `collection.json` records whether a collection is public, but a + repository enforces nothing: whoever can read a storage location can read everything in it. A + server that serves a repository MUST apply `visibility` and the sets (Section 9) itself, and a + location that holds a private collection or a private set MUST NOT be publicly readable. +- **Handles.** A handle names an organization only while the organization holds it + (Section 11.3.1). References meant to last name a collection by its id, or its owner by DID. +- **File serving.** File bytes SHOULD be served from an origin separate from the server's own + pages, and with `Content-Disposition: attachment`, so that an uploaded HTML or SVG file cannot + run in the server's origin. + +## Appendix A. Test vectors + +`packages/protocol/test/vectors/v2.json` contains: + +- the constants (Section 12); +- JCS input and output pairs; +- input-rule verdicts per record line (`inputRules`), covering every code, the depth limit at + its boundary and the order of codes; and, for lines too long to list, a recipe and verdict + (`inputRuleRecipes`), covering the record size limit at its boundary; +- schema acceptance verdicts (`schemaRules`): schemas, with the slug each is given under, + accepted or rejected under Sections 5 and 5.1; +- canonical forms and hashes of records and schemas; +- boundary hashes; +- a list in key order; +- one leaf and one interior node, encoded; +- tree roots for several entry sets: empty, one entry, 1,000 and 100,000 entries, Unicode keys, + and a key set with no natural boundaries, so that every leaf split is forced; +- a file tree; +- two version roots, one with a private set and its commitment; +- file-reference extraction cases; +- one signed log entry, with the key seed that signed it, its signed bytes, its entry hash and the + corresponding `head.json`. + +Tree vectors give a recipe for generating their entries rather than listing them. Ed25519 +signatures are deterministic, so the log entry vector is reproducible. +`packages/protocol/scripts/gen-vectors.ts --check` regenerates the vectors and fails on any +difference. + +## Appendix B. Revision history + +Changes made while implementing the design (rationale in `edge-redesign-build.md`): + +1. Interior entries carry each child's last key, not its first. The boundary rule is defined on + last keys, and a merge needs them to reuse unchanged subtrees without reading them. +2. Record-tree entries carry the record's size, so `bytes` can be verified from nodes alone. +3. Record ids are limited to 1,024 UTF-8 bytes, which bounds node size. +4. The unsafe-integer rule applies to integer literals in the source text. +5. File references have one definition (Section 4); v1 had two. +6. `LEAF_MAX_ENTRIES` is 8,192 (the design had 16,384). Chunking remains fixed-probability rather + than size-aware, since size-aware boundaries depend on position and preclude independent + rebuilding of ranges. +7. Roots are stored as `roots/.json`, without the `ulv2:` prefix. +8. A record leaf's body is one object of one or more gzip members (Section 11), not several + objects. +9. Packs are pull-only (Section 11.2). Clients publish by delta push (Section 11.4); a server + accepts no tree nodes from outside. +10. Log entries carry `collectionId` (Section 11.1). Added 2026-10-03, before any log held real + data. + +Clarifications that change no hash, tree or accepted input: + +11. 2026-10-04: "format 2" renamed "protocol v2". In the reference implementation + `FORMAT_VERSION` became `PROTOCOL_VERSION`, and the vectors file's top-level `format` member + became `protocolVersion`. +12. 2026-10-04: `actorId` written as `null` (Section 11.1). +13. 2026-10-04: `head.versionHash` checked against the last entry (Section 11.1); + `MAX_PATTERN_LENGTH` declared and counted in UTF-16 code units, and `pattern` members inside + `const`, `enum`, `default` and `examples` exempted (Section 5); `bad_id` and `bad_type` for + absent members, and the order of input-rule codes, stated (Section 3); extra members of a + record line ignored (Section 3); `collection.json` serialization declared non-normative + (Section 11.1). +14. 2026-10-05: rewritten in normative form, with requirements language, terminology, a constants + table and security considerations, and aligned with the reference implementation: the input + rules apply to the whole record line, depth included, and their codes follow text order + (Section 3); field-level privacy is defined on `properties` members (Section 5); a declared + file belongs to the private set whether or not records reference it, and stays declared until + removed, where the earlier "unless the push marks it public" described no mechanism + (Section 9); revalidation on a schema change (Section 10.1); `collection.json` has no + `description` (Section 11.1); packs are ordered set by set (Section 11.2); which files are + held for a collection, and the 400, 451 and repeated-commit responses (Sections 11.3, 11.4). +15. 2026-10-05: the reference implementation brought into line with this document, changing no + hash or tree: a key with an invalid escape is `syntax`, not an exception, and a `\u` escape + needs exactly four hex digits (Section 3); field-level `private` and the pattern limit apply + to subschemas whose names are also data keywords (Section 5); a receiver checks the shape of + the root and PrivateSetObject (Section 11.2). The test vectors add `syntax`, `bad_type`, + depth-boundary and code-order cases, `inputRuleRecipes` (`record_too_large`) and + `schemaRules` (Sections 5, 5.1); existing vectors are unchanged. Delete lines are subject to the input rules, and a non-object `metadata_patch` is a 400 (Section 11.4). +16. 2026-10-05: `collection.json` gains `description` (which item 14 had noted as absent), + `visibility` and `ark`; `owner` becomes an object carrying the organization's id, DID, handle + and name; and a writer rewrites the file whenever a member changes (Section 11.1). Section 11.3 + specifies the read API servers already served (the collection, versions, records, record + history, schemas, differences, file list and export) with the recommended collection URL + form, owner handles and DIDs, the collection id form `_/`, and the + `x-underlay-collection` header; the manifest's `files` and `since` are documented. No hash, + tree or accepted input changes. diff --git a/docs/v1-read-api.md b/docs/v1-read-api.md new file mode 100644 index 0000000..308e1e4 --- /dev/null +++ b/docs/v1-read-api.md @@ -0,0 +1,242 @@ +# v1 read API: what v2 must match + +An inventory of v1's read endpoints (repo root `src/api/*`) and what the existing React UI +(`src/routes/**/*.data.ts`, `src/components/*`) actually reads, for the v2 read path. It was +taken from `main` at `7f6e1c6` on 2026-10-03. + +Within each section, "Response" is the shape and "UI reads" is what the UI uses. Where they +differ, only the UI fields are essential. + +## Cross-cutting + +- **Errors**: `{ error, statusCode }`. Uncaught errors give 500 `Internal server error`. +- **Missing vs hidden**: an invisible collection returns 404 `Collection not found`, the same as + a nonexistent one. +- **Auth, in order**: + - `Authorization: Bearer `: an invalid key is 401; + - `?token=` on GET/HEAD: invalid falls back to anonymous; + - the session cookie; + - anonymous for GETs. +- **Scope**: `permissions.collections` (admin > write > read). `metadata.collectionIds` limits a + key to those collections. +- **Owner access** means org membership, and a key's collection list (if any) covering the + collection. **Visible** means public or owner. +- **Rate limit**: a 60 s window. 60 requests per IP when anonymous, 5,000 per user. Sends the + `X-RateLimit-*` headers; over the limit, 429 with `Retry-After`. +- **Semver params** accept `1.0.0`, `v1.0.0` or `1`. They're stored as `v1.2.3`. +- **Share links**: + - A view link is a read key with `{scope:'read', collectionIds:[id], linkShare:true}`, valid 30 + days, used as `//?token=ul_…`. + - An agent link is a 1-hour write key at `/agent/`, which serves an HTML page. + - The UI builds API URLs with `apiUrlBuilder`, which forwards `?token=`. + +## SSR context + +`GET /api/context` uses the session cookie only and never returns 401. + +```ts +{ currentUser: null | { id, slug /*default org*/, displayName, avatarUrl, kfRole, + defaultOrg: {slug, displayName} | null, + orgs: [{ organizationId, slug, displayName, role, isDefault }] }, + mirrorConfig: { enabled, upstream, nodeName, syncSchedule }, + kfAccountUrl, kfAuthUrl } +``` + +- **UI reads**: + - `currentUser.id/slug/displayName/avatarUrl/kfRole/orgs[].slug/isDefault`; + - `isOwner` = `kfRole==='admin' || slug===owner || orgs.some(o => o.slug===owner)`. +- **SSR fetches** go to `http://127.0.0.1:${PORT}` (`fetchBase`). On Workers they must go + in-process (build doc finding 9). + +## Collections + +**`GET /api/collections`** + +- **Query**: `q`, `owner`, `tag`, `sort` (`name`, `records`, `featured`, or default updatedAt), + `mine=true` (needs a session), `limit` (≤100, default 50), `offset`. +- **Response**: + ``` + { collections: [{ id, slug, name, public, ownerSlug, ownerName, createdAt, updatedAt, + description, tags, latestVersion /*semver*/, recordCount, fileCount, totalBytes, lastPushAt }], + facets: { owners: [{slug,name,count}], tags: [{name,count}] }, + featuredTags: string[], featuredCollections: [same item] } + ``` +- **UI reads** (explore and dashboard): `ownerSlug, slug, name, public, description, tags, +latestVersion, recordCount, totalBytes, lastPushAt, updatedAt`, plus the facets and featured + fields. +- **v1 bugs**: sorting, tag filters and facets run in memory over a window. The home page reads + `semver`, which doesn't exist. + +**`GET /api/collections/:owner/:slug`** + +- **Response**: + ``` + { id, slug, name, public, ownerSlug, ownerName, createdAt, updatedAt, description, ark, + versionCount, latestVersion: null | { semver, hash, message, metadata, appId, pushedBy, + baseSemver, recordCount, fileCount, totalBytes, createdAt, typeCounts: [{type,count}] } } + ``` +- **UI reads**: + - collection fields: `public, id, ownerSlug, ownerName, description, versionCount, ark, name, +slug`; + - version fields: `latestVersion.semver`, `.metadata.readme/description/tags`, and the + overview's `semver, recordCount, fileCount, totalBytes, createdAt, typeCounts` (array or + object), `message, baseSemver, appId, pushedBy, hash`. +- Non-owners lose `pushedBy/actorId/signature` and private types in `typeCounts`. + +**`GET /api/accounts/:owner/collections`** + +- **Response**: `[{ id, slug, name, public, createdAt, updatedAt }]`. Members also see private + collections; an unknown org gives `[]`. + +**`GET /api/collections/:owner/:slug/export?version=v1.2.3`** + +- Returns `--.tar.gz` containing: + - `manifest.json`: `{collection:{owner,slug,name,description}, version:{semver,hash,message,recordCount,fileCount,totalBytes,createdAt}, schemas, files_missing}`; + - `records/.ndjson`; + - `files/`. + +## Versions + +**`GET .../versions?limit&offset`** + +- **Response**: a bare array, newest first: + `[{ semver, hash, message, appId, actorId? (owner), recordCount, fileCount, totalBytes, createdAt, ark }]`. +- **UI reads**: `semver, message, recordCount, fileCount, totalBytes, createdAt, hash, ark`. The + version picker uses `?limit=20` and accepts an array or `{versions}`. + +**`GET .../versions/latest`, `GET .../versions/:n`** + +- **Response**: + ``` + { semver, major, minor, patch, hash, baseSemver, message, metadata, pushedBy, appId, actorId, + recordCount, fileCount, typeCounts: {type: n}, totalBytes, createdAt, + schemas: { slug: JSONSchema }, ark } + ``` +- **UI reads**: `semver, schemas` (its keys, and `[t].properties` for table columns), + `recordCount, fileCount, totalBytes, createdAt, appId, hash, ark, message, metadata, +typeCounts, baseSemver, pushedBy`. + +**`GET .../versions/:n/records?type&limit(≤2000, default 100)&offset(≤10000)&after|cursor`** + +- **Response**: + `{ records: [{ id, type, data, hash, ark? }], pagination: { limit, hasMore, nextCursor, total } }`. +- **Cursor**: `base64url(JSON {r:[recordId, recordHash]})`, or a bare id. +- **UI reads**: `records[].id/.data/.hash/.ark` and `pagination.total`. The UI pages by offset; + past offset 10k, v1 answers 400. + +**`GET .../versions/:n/records.ndjson?type&after`** + +- Lines are `{id,type,data,hash}`. `X-Underlay-Record-Count` gives the total. Gzip is applied + when accepted. + +**`GET .../versions/:n/files`** + +- **Response**: a bare array, `[{ hash, size, mimeType, createdAt, references: [{recordId,type,field}] }]`. +- **UI reads**: `hash, mimeType, size, references[].type/.recordId`. + +**`GET .../versions/:n/manifest?since&limit(≤100k)&cursor`** + +- **Full**: + `{ semver, hash, schemas: {slug: schemaHash}, records: [{id,type,hash,private?}], files: string[], pagination }`. +- **Delta**: + `{ semver, hash, since, schemas, delta: {added, updated (+previousHash), removed}, files, pagination, truncated }`. +- Used by the CLI pull and mirror sync, not the UI. + +**`GET .../versions/:n/diff?from&limit(≤5000)&cursor`** + +- **Response**: + `{ from, to, added: [{id,type,data}], updated: [{id,type,data}], removed: string[], pagination, meta: {schemaChanged, metadataChanged, filesAdded, filesRemoved} }`. +- **UI reads**: `added, updated, removed, meta.*`. It also reads `meta.readmeChanged`, which v1 + never returned. + +## Schemas + +- **`GET /api/schemas?q|label|slug|schema_hash&limit&offset`** + - Returns `[{ id, schema, schemaHash, createdAt, labels: string[] }]`. With `schema_hash` it + returns one object plus `usageCount`. + - A schema is visible when it's non-private in a public collection, or in one of the caller's + orgs. +- **`GET /api/schemas/:id`** + - Returns + `{ id, schema, schemaHash, createdAt, labels: [{label, createdAt}], usage: [{slug, semver, collection: "owner/slug"}] }`. +- **`GET /api/collections/:owner/:slug/schemas?version&raw`** + - Returns + `{ version, semver, schemas: [{ slug, schemaId, schemaHash, schema (+ 'x-underlay-labels') }] }`. + +## Records, files, accounts + +- **`GET /api/records/:hash/provenance`** + - Returns + `{ hash, recordId, type, data, size, createdAt, firstSeen, references: [{owner, collection, collectionName, semver, versionCreatedAt}] }`. + - References are public collections only. +- **`POST /api/records/batch`**: `{hashes}` in, NDJSON out. +- **Files**: + - `HEAD|GET /api/collections/:owner/:slug/files/:hash`: GET is a 302 to a presigned URL with + `attachment`. + - `GET /api/collections/files/:hash`: the same, for any collection the caller can read. + - `POST .../files/presign {hashes}` returns `{hash: url|null}`. +- **Accounts**: + - `GET /api/accounts/:slug` returns the org row, plus `displayName` and `arkShoulder`. + - `GET /api/accounts/:slug/members` returns `[{role, slug, displayName}]`. + - `GET /api/accounts/me` returns + `{id, name, email, image, slug, displayName, createdAt, orgs:[{organizationId, role, slug, name, isDefault}]}`. +- **ARK**: + - `GET /api/ark/resolve?path=ark:…` returns `{type:'redirect', url, metadata}`, or 404 + `{type:'not_found'}`. + - `GET .../ark` returns `{enabled, customUrl, arkUrl, shoulder, arkId}`. + - `GET .../ark/record-types` returns `[{recordType, redirectUrlField}]`. +- **Webhooks**: + - `GET .../webhooks` returns + `{webhooks:[{id,url,bumpFilter,enabled,createdAt,lastDeliveryAt}]}`. + - `GET .../webhooks/:id/deliveries` returns `{deliveries:[…]}`. +- **Health**: `GET /api/health` returns `{status:'ok', timestamp}`. +- **KF summary**: `GET /api/kf/summary?kf_org_id`, with the KF internal key, returns per-org + collection stats. + +## v1 behaviour v2 deliberately changes or fixes + +- **Records order**: + - With `?type=`, records are in id order. + - Without it, they're in (type, id) order, and the cursor becomes (type, id) (edge-redesign.md + Read path). + - Offsets have no 10k cap: they cost O(height) in v2. +- **Non-owner hashes and counts**: + - Non-owners get the public set's real contents, and its counts are exact, not upper bounds. + - A version's `hash` is the v2 version hash for everyone. There's no separate public hash. +- **Field-level privacy is gone**, so nothing is stripped from records. +- **Manifest `files` and version `/files`** come from the set's file tree and are + privacy-correct. The manifest lists files on its first page only, at most 25,000, then + `filesTruncated: true`; `/files` returns at most 10,000, with `references` always `[]` and a + `referenceCount`. +- **Inconsistent v1 fields** (`typeCounts` array vs object, `labels` list vs objects) are kept + where the UI depends on them. +- **Auth**: an invalid `?token=` is a 401, as a Bearer key is; it no longer falls back to + anonymous. Org-owned keys spend their org's rate budget. +- **Rate limits**: no `X-RateLimit-*` headers; a 429 has `Retry-After: 60`. Anonymous pages + (ARK resolution and `/api/auth/*` included) have their own budget of 600 a minute per IP, and + expensive reads cost more than one unit (`packages/server/src/lib/limits.ts`). +- **Version params** also accept a `ulv2:` version hash. +- **`/api/context`**: no `mirrorConfig`; adds `siteHost`. Server-rendered pages call the API + in-process on both runtimes. +- **Records**: no per-record `ark`; members' private records carry `private: true`. +- **records.ndjson**: no server-side gzip. `?after_type=T&after=id` resumes after (T, id) + through the later types; `?type=T&after=id` stays within T; `after` with neither, or + `after_type` without `after`, is a 400. `X-Underlay-Record-Count` counts what the request + returns, after the resume point. Members' private lines end `"private":true`. + `records.ndjson.gz?type=` is new (one type's public records as stored). +- **Manifest**: the default limit is 10,000 and the maximum 25,000; no `truncated` field; delta + `removed` entries are `{id, type, hash}`. For members, delta entries in the private set carry + `private: true`, and a move between sets is under `updated` with `previousPrivate` (with + `previousHash` equal to `hash` when the record didn't change). +- **Diff**: without `from`, the diff is against the version before (the first version's is + against nothing); `removed` entries are `{id, type}`, not bare ids. +- **Provenance**: covers the caller's own organizations' collections as well as public ones, + adds `recordHash`, and is capped at 300 presences and 100 versions per collection. +- **`POST /api/records/batch`**: 1 to 100 hashes. +- **Export**: `?format=tar|tar.gz`; `manifest.json` adds `files_withheld`, and `README.md` is + included when the metadata has a readme. Tar entries carry the version's creation time, so a + version exports to the same bytes every time. +- **Health**: `{ok: true, version: 2, deployment, time}`. +- **Agent links**: `GET /agent/` is ported. It serves the HTML instructions page for the + share panel's 1-hour, one-collection write key, now describing delta push; a key that isn't + such a live key gets a 404 page. diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100644 index d81dc9d..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Config } from 'drizzle-kit' - -export default { - schema: './src/db/schema.ts', - out: './src/db/migrations', - dialect: 'postgresql', - dbCredentials: { - url: process.env.DATABASE_URL ?? 'postgresql://underlay:underlay@localhost:5432/underlay', - }, -} satisfies Config diff --git a/package.json b/package.json index cf45a5d..6eefbab 100644 --- a/package.json +++ b/package.json @@ -4,90 +4,18 @@ "private": true, "type": "module", "scripts": { - "dev": "./dev.sh", - "dev:app": "tsx watch --clear-screen=false --env-file=.env.local server.ts", - "build": "vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server", - "start": "NODE_ENV=production node --import tsx/esm server.ts", - "typecheck": "tsc --noEmit", "lint": "oxlint .", "fmt": "oxfmt", "fmt:check": "oxfmt --check", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx src/db/migrate.ts", - "db:seed": "tsx src/db/seed.ts", - "db:seed-kf": "tsx src/db/seedKfCollections.ts", - "tool:backup": "tsx tools/backupDb.ts", - "tool:restore": "tsx tools/restore.ts", - "tool:pruneBackups": "tsx tools/pruneBackups.ts", - "tool:cleanupSessions": "tsx tools/cleanupSessions.ts", - "tool:pruneWebhookLogs": "tsx tools/pruneWebhookLogs.ts", - "tool:seed-mirror": "tsx tools/seedMirror.ts", - "tool:verifyRecordSharing": "tsx tools/verifyRecordSharing.ts", - "tool:verifyFileRefs": "tsx tools/verifyFileRefs.ts", - "cli": "tsx src/cli/cli.ts", - "test": "vitest run", - "test:watch": "vitest", - "secrets:encrypt:local": "sops -e --input-type dotenv --output-type dotenv --output .env.local.enc .env.local", - "secrets:encrypt:prod": "sops -e --input-type dotenv --output-type dotenv --output .env.prod.enc .env.prod", - "secrets:encrypt:dev": "sops -e --input-type dotenv --output-type dotenv --output .env.dev.enc .env.dev", - "secrets:decrypt:local": "sops -d --input-type dotenv --output-type dotenv --output .env.local .env.local.enc", - "secrets:decrypt:prod": "sops -d --input-type dotenv --output-type dotenv --output .env.prod .env.prod.enc", - "secrets:decrypt:dev": "sops -d --input-type dotenv --output-type dotenv --output .env.dev .env.dev.enc" - }, - "dependencies": { - "@aws-sdk/client-s3": "^3.750.0", - "@aws-sdk/s3-request-presigner": "^3.1104.0", - "@better-auth/api-key": "^1.6.11", - "@codemirror/autocomplete": "^6.20.1", - "@codemirror/commands": "^6.10.3", - "@codemirror/lang-sql": "^6.10.0", - "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.41.1", - "@hono/node-server": "^1", - "@scalar/hono-api-reference": "^0.11.0", - "@tanstack/react-query": "^5.101.0", - "ajv": "^8.17.0", - "ajv-formats": "^3.0.0", - "better-auth": "^1.6.11", - "better-sqlite3": "^12.9.0", - "commander": "^14.0.0", - "drizzle-orm": "^0.45.0", - "hono": "^4", - "hono-zod-openapi": "^1.1.1", - "isomorphic-dompurify": "^3.16.0", - "lucide-react": "^1.11.0", - "marked": "^18.0.0", - "node-cron": "^4.0.0", - "postgres": "^3.4.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-router": "^7", - "sql.js": "^1.14.1", - "tar-stream": "^3.1.8", - "tsx": "^4.19.0", - "undici": "7.27.2", - "uuid": "^14.0.0", - "zod": "^4.4.3" + "typecheck": "pnpm --filter \"./packages/*\" typecheck", + "test": "pnpm --filter \"./packages/*\" --workspace-concurrency=1 test" }, "devDependencies": { - "@tailwindcss/vite": "^4.1.0", - "@types/better-sqlite3": "^7.6.13", - "@types/node": "^25.0.0", - "@types/react": "^19.1.0", - "@types/react-dom": "^19.1.0", - "@types/tar-stream": "^3.1.4", - "@vitejs/plugin-react": "^5.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "drizzle-kit": "^0.31.0", - "happy-dom": "^20.9.0", "lint-staged": "^17.0.4", "oxfmt": "latest", "oxlint": "latest", "simple-git-hooks": "^2.13.1", - "tailwindcss": "^4.1.0", - "typescript": "^6.0.0", - "vite": "^6", - "vitest": "^4.1.6" + "tsx": "^4.19.0" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/packages/cli/README.md b/packages/cli/README.md index aa0e02b..5af9c6d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,36 +1,40 @@ # @underlay/cli -Source lives in `src/cli`; this package just bundles it (`pnpm --filter @underlay/cli build`). - -Currently **unpublished and not built** — runnable only from the repo via `pnpm cli`. - -## ⚠️ Review record privacy before publishing this for the first time - -The CLI predates per-version record privacy (`version_records.private`, added 2026-08) and -**cannot express it**. Publishing as-is would put a privacy-blind client in users' hands. - -Two concrete gaps, both must be closed first: - -1. **`src/cli/commands/push.ts` — manifest entries omit `private`.** The manifest is built as - `{ id, type, hash }`. The server takes each push's manifest as the authoritative statement of - which records are private, so a push that omits the flag marks every record public. Re-pushing - a collection that has private records would **silently publish them** in the new version. -2. **`src/cli/commands/add.ts` — `private` is dropped at ingest.** It parses only - `{ id, type, data }` and stores the canonical hashed object, which by design excludes privacy - (privacy is contextual, not part of the content hash). So the flag is lost before `push` could - ever send it. The local store needs somewhere to carry it — e.g. a per-record privacy set in - the version manifest (`src/cli/lib/store.ts`, `VersionManifest`), kept outside the hash. - -Also settle the server-side semantics this depends on before shipping: a manifest entry's -`private` is `z.boolean().optional()`, but ingest currently collapses it (`r.private ?? false`), -so **omitted means public**. If that becomes "omitted means inherit from the base version", the -CLI's obligations change. See `planning/reference-privacy-model.md`. - -## Also check before publishing - -- **The npm name `@underlay/cli` is already taken** by a 2023 package from the earlier Underlay - project ("CLI utility for downloading datasets specified in underlay.yaml", maintainers - `octref`, `joelg@mit.edu`, author "Knowledge Futures Group"). Publishing needs that account, a - version above `0.0.1`, or a different name. -- The CLI is several releases stale against the API — verify it against the current negotiate - protocol (chunked manifest, async commit) before shipping. +The Underlay v2 command line. Source is in `src/`; `pnpm --filter @underlay/cli build` bundles it +to `dist/cli.js`. Unpublished. + +A working directory holds a local repository in `.underlay/`: a repository in the protocol layout +(`repo/`), local versions, staging and remotes (`src/local.ts`). Versions are built with +`buildVersion` from `@underlay/protocol`, the registry's own commit engine, so pushing a version +gives the registry the same trees. + +``` +underlay init [dir] | clone [dir] [--token] +underlay schema-set stage the type set ({type: schema}) +underlay add [--strip-unknown-fields] + stage records (NDJSON {id, type, data, private?}) +underlay rm stage deletes +underlay meta-set | --clear +underlay file add store files records reference ({"$file":"sha256:…"}) +underlay commit -m +underlay status | log | diff +underlay fsck [--files] verify the local repository +underlay remote add -c [-t ] | remove | list +underlay pull [remote] [--force] remote defaults to origin +underlay push [remote] +``` + +- **pull** verifies the registry's signed log after what was last seen, then receives the newest + version as a pack against the last synced one; every tree is re-derived before it's accepted. + With a token it asks for the private sets too (`sets=all`), and falls back to the public sets + if the token can't read them. +- **push** sends the local changes since the last sync as a delta push (upserts with their set, + and deletes), uploads the files they need, and then requires the registry's new version to be + the local one: the same hash, or, when the registry's private salt differs from a new local + repository's, the same records, files and metadata, checked by pulling it back. It refuses + while the registry has versions not yet pulled. +- **Privacy** is carried: `private: true` on a record puts it in the private set locally and on + the registry, and private types (`"private": true` in the schema) are private throughout. + +Before publishing: the npm name `@underlay/cli` belongs to a 2023 package from the earlier +Underlay project, so publishing needs that account or another name. diff --git a/packages/cli/package.json b/packages/cli/package.json index 0a60401..2831863 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,7 @@ { "name": "@underlay/cli", - "version": "0.1.0", + "version": "0.2.0", + "description": "The Underlay command line: a local repository in .underlay/, pulled and pushed with a registry.", "bin": { "underlay": "./dist/cli.js" }, @@ -9,9 +10,18 @@ ], "type": "module", "scripts": { - "build": "esbuild ../../src/cli/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node'" + "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\nimport { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\"", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@underlay/protocol": "workspace:*", + "commander": "^14.0.3" }, "devDependencies": { - "esbuild": "^0.25.0" + "@types/node": "^25.0.0", + "esbuild": "^0.27.7", + "typescript": "^6.0.0", + "vitest": "^4.1.6" } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..87f17d3 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,147 @@ +/** + * underlay: the command line for Underlay v2. A local repository in .underlay/ + * (local.ts), versions built by the protocol's commit engine, and tree sync with + * a registry. + */ +import { Command } from 'commander' + +import { commit } from './commands/commit.js' +import { diff, fsck, log, remoteAdd, remoteList, remoteRemove, status } from './commands/info.js' +import { add, fileAdd, metaSet, rm, schemaSet } from './commands/stage.js' +import { clone, pull, push } from './commands/sync.js' +import { CliError, Local } from './local.js' + +const say = (line: string) => console.log(line) +const here = () => Local.require() +/** Commander wants actions that resolve to nothing. */ +const run = async (p: Promise) => { + await p +} + +const program = new Command() + .name('underlay') + .description('Underlay: versioned, content-addressed collections of records') + .version('0.2.0') + +program + .command('init') + .description('Create a local repository') + .argument('[dir]', 'directory', '.') + .action((dir: string) => { + Local.init(dir) + say(`Initialized an Underlay repository in ${dir}`) + }) + +program + .command('clone') + .description('Create a local repository from a registry collection') + .argument('', 'registry URL, e.g. https://www.underlay.org') + .argument('', 'owner/slug') + .argument('[dir]', 'directory (default: the slug)') + .option('-t, --token ', 'API key (also fetches the private sets you can read)') + .action((url: string, collection: string, dir: string | undefined, o: { token?: string }) => + run(clone(url, collection, dir ?? collection.split('/')[1]!, o, say)), + ) + +program + .command('schema-set') + .description('Stage the type set from a JSON file of {type: schema}') + .argument('') + .action((file: string) => schemaSet(here(), file, say)) + +program + .command('add') + .description('Stage records from an NDJSON file of {id, type, data, private?}') + .argument('') + .option('--strip-unknown-fields', 'drop fields the schema does not define') + .action((file: string, o: { stripUnknownFields?: boolean }) => run(add(here(), file, o, say))) + +program + .command('rm') + .description('Stage deletes') + .argument('') + .argument('') + .action((type: string, ids: string[]) => rm(here(), type, ids, say)) + +program + .command('meta-set') + .description('Stage the version metadata from a JSON file') + .argument('[file]') + .option('--clear', 'stage no metadata') + .action((file: string | undefined, o: { clear?: boolean }) => { + if (!file && !o.clear) throw new CliError('Give a metadata file, or --clear') + metaSet(here(), o.clear ? null : file!, say) + }) + +const files = program.command('file').description('Files records can reference') +files + .command('add') + .description('Store files locally and print their $file references') + .argument('') + .action((paths: string[]) => run(fileAdd(here(), paths, say))) + +program + .command('status') + .description('Show the head, remotes and staged changes') + .action(() => status(here(), say)) + +program + .command('commit') + .description('Make a local version from the staged changes') + .requiredOption('-m, --message ') + .action((o: { message: string }) => run(commit(here(), o.message, say))) + +program + .command('log') + .description('List local versions') + .action(() => log(here(), say)) + +program + .command('diff') + .description('Compare two local versions') + .argument('') + .argument('') + .action((from: string, to: string) => diff(here(), from, to, say)) + +program + .command('fsck') + .description('Check the local repository: versions, trees, bodies, files and logs') + .option('--files', 'hash every file, not just check it is there at its size') + .action((o: { files?: boolean }) => run(fsck(here(), o, say))) + +const remote = program.command('remote').description('Manage registries') +remote + .command('add') + .argument('') + .argument('') + .requiredOption('-c, --collection ') + .option('-t, --token ', 'API key') + .action((name: string, url: string, o: { collection: string; token?: string }) => + remoteAdd(here(), name, url, o, say), + ) +remote + .command('remove') + .argument('') + .action((name: string) => remoteRemove(here(), name, say)) +remote.command('list').action(() => remoteList(here(), say)) + +program + .command('pull') + .description("Fetch a remote's newest version and make it the head") + .argument('[remote]', 'remote name', 'origin') + .option('--force', 'replace local versions that were not pushed') + .action((name: string, o: { force?: boolean }) => run(pull(here(), name, o, say))) + +program + .command('push') + .description('Publish the head to a remote') + .argument('[remote]', 'remote name', 'origin') + .action((name: string) => run(push(here(), name, {}, say))) + +program.parseAsync().catch((err: unknown) => { + if (err instanceof CliError) { + console.error(err.message) + process.exit(1) + } + throw err +}) diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts new file mode 100644 index 0000000..7c1b497 --- /dev/null +++ b/packages/cli/src/client.ts @@ -0,0 +1,94 @@ +/** HTTP access to a registry collection (the v2 API). */ +import { + type CollectionInfo, + type LogEntry, + type PackObject, + type SyncSets, + untar, +} from '@underlay/protocol' + +import { CliError, type RemoteConfig } from './local.js' + +export type Fetch = (url: string, init?: RequestInit) => Promise + +export interface LogPage { + collection: CollectionInfo | null + head: { seq: number; entryHash: string; versionHash: string } | null + entries: LogEntry[] +} + +export class Client { + readonly base: string + + constructor( + readonly remote: RemoteConfig, + readonly fetchImpl: Fetch = (url, init) => fetch(url, init), + ) { + this.base = `${remote.url.replace(/\/+$/, '')}/api/collections/${remote.collection}` + } + + async request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers) + if (this.remote.token) headers.set('authorization', `Bearer ${this.remote.token}`) + return this.fetchImpl(`${this.base}${path}`, { ...init, headers }) + } + + async json(path: string, init: RequestInit = {}): Promise { + const res = await this.request(path, init) + const body = (await res.json().catch(() => null)) as (T & { error?: string }) | null + if (!res.ok) { + throw new CliError( + `${init.method ?? 'GET'} ${path}: ${res.status} ${body?.error ?? res.statusText}`, + ) + } + return body as T + } + + post(path: string, body: unknown): Promise { + return this.json(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + postNdjson(path: string, lines: string[]): Promise { + return this.json(path, { + method: 'POST', + headers: { 'content-type': 'application/x-ndjson' }, + body: lines.join('\n') + '\n', + }) + } + + /** Log entries after `after` (every page). */ + async log(after: number): Promise { + const first = await this.json(`/log?after=${after}`) + const entries = [...first.entries] + while (first.head && entries.length > 0 && entries[entries.length - 1]!.seq < first.head.seq) { + const page = await this.json(`/log?after=${entries[entries.length - 1]!.seq}`) + if (page.entries.length === 0) break + entries.push(...page.entries) + } + return { ...first, entries } + } + + /** A version's pack against a base; with `all`, falls back to public when refused. */ + async pack( + version: string, + base: string | null, + sets: SyncSets, + ): Promise<{ objects: AsyncIterable; sets: SyncSets }> { + const query = new URLSearchParams({ sets }) + if (base) query.set('base', base) + const res = await this.request(`/versions/${encodeURIComponent(version)}/pack?${query}`) + if (res.status === 403 && sets === 'all') return this.pack(version, base, 'public') + if (!res.ok || !res.body) throw new CliError(`Fetching ${version}: ${res.status}`) + const body = res.body + return { + sets, + objects: (async function* () { + for await (const f of untar(body)) yield { key: f.name, bytes: f.bytes } + })(), + } + } +} diff --git a/packages/cli/src/commands/commit.ts b/packages/cli/src/commands/commit.ts new file mode 100644 index 0000000..6360c1c --- /dev/null +++ b/packages/cli/src/commands/commit.ts @@ -0,0 +1,208 @@ +/** + * `underlay commit`: staged changes become a local version, built by the same + * engine the registry commits with (`buildVersion`), so the registry builds the + * same trees when the version is pushed. + */ +import { + buildVersion, + type BuildTypeInput, + type Change, + compareUtf8, + compileSchema, + deriveSemver, + fileTree, + hashSchema, + isPrivateSchema, + iterate, + keys, + MissingFilesError, + rebuildFileRefs, + OUT_OF_LINE_BYTES, + type RecordEntry, + RepoSource, + type SetObject, + sha256Hex, + utf8ByteLength, +} from '@underlay/protocol' + +import { CliError, type Local, type LocalVersion, type StagedOp } from '../local.js' +import { versionState, type VersionState } from '../state.js' +import type { Say } from './stage.js' + +/** + * Sizes of files entering a set: from the local file store (`underlay file + * add`), else from the file trees of the versions this repository already has. + */ +function fileSizer(local: Local, known: SetObject[]) { + return async (hashes: string[]) => { + const out = new Map() + for (const h of hashes) { + const head = await local.repo.blobs.head(keys.file(h)) + if (head) out.set(h, head.size) + } + const missing = new Set(hashes.filter((h) => !out.has(h))) + for (const set of known) { + if (missing.size === 0) break + for await (const e of iterate(new RepoSource(fileTree, local.repo), set.files.root)) { + if (missing.delete(e.key)) out.set(e.key, e.size) + } + } + return out + } +} + +/** + * The file reference count trees of a version. A pulled version doesn't carry + * them (they're writer bookkeeping), so they're rebuilt once from its records: + * every reference counts, and a private-set file no record references was + * declared. + */ +export async function ensureRefs( + local: Local, + state: VersionState, +): Promise<{ public: string | null; private: string | null }> { + if (state.version.refs) return state.version.refs + let refs + try { + refs = await rebuildFileRefs(local.repo, { public: state.public, private: state.private }) + } catch (err) { + throw new CliError(`${state.version.semver}: ${(err as Error).message}`) + } + local.writeVersion({ ...state.version, refs }) + return refs +} + +/** The staged operations, last one per (type, id) winning, sorted per type. */ +function opsByType(ops: StagedOp[]): Map { + const last = new Map() + for (const op of ops) last.set(`${op.type}\u0000${op.id}`, op) + const byType = new Map() + for (const op of last.values()) byType.set(op.type, [...(byType.get(op.type) ?? []), op]) + for (const list of byType.values()) list.sort((a, b) => compareUtf8(a.id, b.id)) + return byType +} + +export async function commit(local: Local, message: string, say: Say): Promise { + const head = local.headVersion() + const base = head ? await versionState(local, head) : null + const stagedSchemas = local.stagedSchemas() + const stagedMetadata = local.stagedMetadata() + const ops = local.stagedOps() + if (!stagedSchemas && stagedMetadata === undefined && ops.length === 0) { + throw new CliError('Nothing staged.') + } + const schemas = stagedSchemas ?? base?.schemas ?? {} + if (Object.keys(schemas).length === 0) + throw new CliError('No schemas. Run `underlay schema-set`.') + const metadata = stagedMetadata !== undefined ? stagedMetadata : (base?.root.metadata ?? null) + const repo = local.repo + const byType = opsByType(ops) + for (const type of byType.keys()) { + if (!schemas[type]) throw new CliError(`Staged records of type "${type}", which has no schema`) + } + + const types: BuildTypeInput[] = [] + for (const [slug, schema] of Object.entries(schemas)) { + const privateType = isPrivateSchema(schema) + const inPub = !!base?.public.types[slug]?.root + const inPriv = !!base?.private.types[slug]?.root + const validate = compileSchema(schema) + const typeOps = byType.get(slug) ?? [] + // Revalidate staged records against the schema they will be committed under. + for (const op of typeOps) { + if (op.op !== 'put') continue + const errs = validate((JSON.parse(op.canonical) as { data: unknown }).data) + if (errs.length > 0) throw new CliError(`${slug} ${op.id}: ${errs.join('; ')}`) + } + const entries = new Map() + for (const op of typeOps) { + if (op.op !== 'put') continue + const hash = sha256Hex(op.canonical) + const size = utf8ByteLength(op.canonical) + const body = + size > OUT_OF_LINE_BYTES ? await repo.putOutOfLine(hash, op.canonical) : op.canonical + entries.set(op, { key: op.id, hash, size, body }) + } + // As the registry splits a delta push: an upsert goes to its set and is a + // delete in the other; a delete goes to both. + const stream = (set: 'public' | 'private'): Change[] => { + const inBase = set === 'public' ? inPub : inPriv + const out: Change[] = [] + for (const op of typeOps) { + if (op.op === 'del') { + if (inBase) out.push({ key: op.id, entry: null }) + continue + } + const target = privateType || op.private ? 'private' : 'public' + if (target === set) out.push({ key: op.id, entry: { ...entries.get(op)! } }) + else if (inBase) out.push({ key: op.id, entry: null }) + } + return out + } + types.push({ + slug, + schema, + schemaHash: hashSchema(schema), + public: privateType ? null : stream('public'), + private: stream('private'), + }) + } + + const refs = base ? await ensureRefs(local, base) : null + const known = base ? [base.public, base.private] : [] + let built + try { + built = await buildVersion(repo, { + base: base + ? { hash: base.version.hash, publicRefsRoot: refs!.public, privateRefsRoot: refs!.private } + : null, + types, + metadata, + salt: local.salt(), + fileSizes: fileSizer(local, known), + validate: (schema, data) => { + const errs = compileSchema(schema)(data) + return errs.length > 0 ? errs : null + }, + }) + } catch (err) { + if (err instanceof MissingFilesError) { + throw new CliError( + `Records reference files this repository doesn't have; add them with \`underlay file add\`:\n ${err.hashes.join('\n ')}`, + ) + } + throw err + } + if (built.status === 'invalid') { + throw new CliError( + `The new schemas reject ${built.total} existing record(s):\n ${built.errors + .slice(0, 20) + .map((e) => `${e.type} ${e.recordId}: ${e.errors.join('; ')}`) + .join('\n ')}`, + ) + } + if (base && built.versionHash === base.version.hash) { + local.clearStaging() + throw new CliError('No changes: the staged changes leave the version as it is.') + } + const sv = deriveSemver(head?.semver ?? null, built.schemaChanged, built.recordsChanged) + if (local.version(sv.semver)) { + throw new CliError(`Version ${sv.semver} already exists here`) + } + const version: LocalVersion = { + semver: sv.semver, + hash: built.versionHash, + baseSemver: head?.semver ?? null, + message, + createdAt: new Date().toISOString(), + refs: { public: built.publicRefsRoot, private: built.privateRefsRoot }, + sets: 'all', + } + local.writeVersion(version) + local.setHead(version.semver) + local.clearStaging() + const { added, removed, updated } = built.stats + say(`${sv.semver} ${built.versionHash.slice(0, 17)}… ${message}`) + say(` +${added} ~${updated} -${removed} record(s)`) + return version +} diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts new file mode 100644 index 0000000..6e2e30c --- /dev/null +++ b/packages/cli/src/commands/info.ts @@ -0,0 +1,141 @@ +/** Read-only commands: status, log, diff, and managing remotes. */ +import { diffTrees, fsck as checkRepo, listAll, recordTree, RepoSource } from '@underlay/protocol' + +import { CliError, type Local } from '../local.js' +import { versionState } from '../state.js' +import type { Say } from './stage.js' + +export function status(local: Local, say: Say): void { + const head = local.headVersion() + say(head ? `On ${head.semver} (${head.hash.slice(0, 17)}…)` : 'No versions yet.') + for (const name of Object.keys(local.remotes())) { + const t = local.tracking(name) + if (!t) say(` ${name}: never synced`) + else if (head && t.hash === head.hash) say(` ${name}: up to date at ${t.semver}`) + else say(` ${name}: last synced at ${t.semver}; ${head?.semver ?? 'nothing'} not pushed`) + } + const schemas = local.stagedSchemas() + const metadata = local.stagedMetadata() + const ops = local.stagedOps() + if (!schemas && metadata === undefined && ops.length === 0) { + say('Nothing staged.') + return + } + say('Staged:') + if (schemas) say(` schemas: ${Object.keys(schemas).join(', ')}`) + if (metadata !== undefined) say(metadata === null ? ' metadata: cleared' : ' metadata') + const puts = ops.filter((o) => o.op === 'put').length + if (puts > 0) say(` ${puts} upsert(s)`) + if (ops.length > puts) say(` ${ops.length - puts} delete(s)`) +} + +export function log(local: Local, say: Say): void { + const head = local.head() + const versions = local.versions() + if (versions.length === 0) say('No versions yet.') + for (const v of versions.reverse()) { + const mark = v.semver === head ? '*' : ' ' + const from = v.remote ? ` [${v.remote}]` : '' + say( + `${mark} ${v.semver} ${v.createdAt.slice(0, 19)} ${v.hash.slice(0, 17)}…${from} ${v.message ?? ''}`, + ) + } +} + +/** Records added, changed and removed between two local versions, per type. */ +export async function diff(local: Local, from: string, to: string, say: Say): Promise { + const a = local.version(from) + const b = local.version(to) + if (!a || !b) throw new CliError(`No local version ${!a ? from : to}`) + const sa = await versionState(local, a) + const sb = await versionState(local, b) + const source = new RepoSource(recordTree, local.repo) + const slugs = new Set([...Object.keys(sa.schemas), ...Object.keys(sb.schemas)]) + for (const slug of [...slugs].sort()) { + const n = { added: 0, updated: 0, removed: 0 } + const sample: string[] = [] + for (const set of ['public', 'private'] as const) { + for await (const d of diffTrees( + source, + sa[set].types[slug]?.root ?? null, + sb[set].types[slug]?.root ?? null, + )) { + const kind = !d.before ? 'added' : !d.after ? 'removed' : 'updated' + n[kind]++ + if (sample.length < 5) + sample.push(`${kind === 'added' ? '+' : kind === 'removed' ? '-' : '~'}${d.key}`) + } + } + if (!sa.schemas[slug]) say(`${slug}: new type`) + else if (!sb.schemas[slug]) say(`${slug}: removed`) + if (n.added + n.updated + n.removed > 0) { + say(`${slug}: +${n.added} ~${n.updated} -${n.removed} ${sample.join(' ')}`) + } + } + if (JSON.stringify(sa.root.metadata) !== JSON.stringify(sb.root.metadata)) say('metadata changed') +} + +export function remoteAdd( + local: Local, + name: string, + url: string, + opts: { collection?: string; token?: string }, + say: Say, +): void { + if (!opts.collection || !/^[^/]+\/[^/]+$/.test(opts.collection)) { + throw new CliError('Give the collection as --collection owner/slug') + } + const remotes = local.remotes() + if (remotes[name]) throw new CliError(`Remote "${name}" already exists`) + remotes[name] = { url, collection: opts.collection, ...(opts.token ? { token: opts.token } : {}) } + local.setRemotes(remotes) + say(`Added ${name}: ${url} ${opts.collection}`) +} + +export function remoteRemove(local: Local, name: string, say: Say): void { + const remotes = local.remotes() + if (!remotes[name]) throw new CliError(`No remote named "${name}"`) + delete remotes[name] + local.setRemotes(remotes) + local.setTracking(name, null) + say(`Removed ${name}`) +} + +export function remoteList(local: Local, say: Say): void { + for (const [name, r] of Object.entries(local.remotes())) { + say(`${name} ${r.url} ${r.collection}${r.token ? ' (token)' : ''}`) + } +} + +/** + * Check the local repository: every local version's root, trees, bodies and + * files, and any collection log stored in it (under the keys its + * collection.json declares, which shows integrity, not who signed). A clone + * verifies the remote's log over the API and doesn't keep it. + */ +export async function fsck(local: Local, opts: { files?: boolean }, say: Say): Promise { + const repo = local.repo + const ids = new Set() + for await (const k of listAll(repo.blobs, 'collections/')) { + const id = k.split('/')[1] + if (id) ids.add(id) + } + let ok = true + const show = (what: string, r: Awaited>) => { + say( + `${what}: ${r.ok ? 'ok' : 'PROBLEMS'} (${r.versions} versions, ${r.trees} trees, ${r.leaves} leaves, ${r.records} records, ${r.files} files)`, + ) + for (const e of r.errors) say(` ${e}`) + if (r.moreErrors) say(` …and ${r.moreErrors} more`) + ok &&= r.ok + } + show( + 'local versions', + await checkRepo(repo, { + versions: local.versions().map((v) => v.hash), + fileBytes: !!opts.files, + }), + ) + for (const id of ids) show(`log of ${id}`, await checkRepo(repo, { collectionId: id })) + if (!ok) throw new CliError('fsck found problems') +} diff --git a/packages/cli/src/commands/stage.ts b/packages/cli/src/commands/stage.ts new file mode 100644 index 0000000..1ef3699 --- /dev/null +++ b/packages/cli/src/commands/stage.ts @@ -0,0 +1,151 @@ +/** + * Staging: what the next `underlay commit` will contain. Records go through the + * same input rules and schema validation as the registry's ingest, so what + * stages here is what a push will accept. + */ +import { createReadStream, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { createInterface } from 'node:readline' + +import { + checkSchema, + compileSchema, + InputRuleError, + isPrivateSchema, + keys, + parseRecordLine, + recordCanonical, + sha256Hex, + stripToSchema, +} from '@underlay/protocol' + +import { CliError, type Local, type StagedOp } from '../local.js' +import { versionState } from '../state.js' + +export type Say = (line: string) => void + +/** The schemas the next commit will have: staged, else the head's. */ +export async function currentSchemas( + local: Local, +): Promise>> { + const staged = local.stagedSchemas() + if (staged) return staged + const head = local.headVersion() + return head ? (await versionState(local, head)).schemas : {} +} + +/** Stage the full type set from a JSON file `{slug: schema, …}`. */ +export async function schemaSet(local: Local, file: string, say: Say): Promise { + const schemas = JSON.parse(readFileSync(resolve(file), 'utf8')) as unknown + if (!schemas || typeof schemas !== 'object' || Array.isArray(schemas)) { + throw new CliError('A schema file is an object of type → schema') + } + const out: Record> = {} + for (const [slug, body] of Object.entries(schemas)) { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new CliError(`Schema "${slug}" must be an object`) + } + const err = checkSchema(slug, body) + if (err) throw new CliError(err) + compileSchema(body) + await local.repo.putSchema(body) + out[slug] = body as Record + } + if (Object.keys(out).length === 0) throw new CliError('The schema file defines no types') + local.stageSchemas(out) + say(`Staged ${Object.keys(out).length} type(s): ${Object.keys(out).join(', ')}`) +} + +export interface AddOptions { + /** Drop fields the schema doesn't define instead of refusing the record. */ + stripUnknownFields?: boolean +} + +/** Stage upserts from an NDJSON file of `{id, type, data, private?}`. */ +export async function add(local: Local, file: string, opts: AddOptions, say: Say): Promise { + const schemas = await currentSchemas(local) + if (Object.keys(schemas).length === 0) { + throw new CliError('No schemas yet. Stage them first with `underlay schema-set`.') + } + const errors: string[] = [] + const ops: StagedOp[] = [] + let line = 0 + const rl = createInterface({ input: createReadStream(resolve(file)), crlfDelay: Infinity }) + for await (const text of rl) { + line++ + if (text.trim() === '') continue + try { + const rec = parseRecordLine(text) + const schema = schemas[rec.type] + if (!schema) throw new CliError(`No schema for type "${rec.type}"`) + let { data, canonical } = rec + const props = schema.properties as Record | undefined + if (props && data !== null && typeof data === 'object' && !Array.isArray(data)) { + const extra = Object.keys(data).filter((k) => !(k in props)) + if (extra.length > 0) { + if (!opts.stripUnknownFields) { + throw new CliError( + `Fields not in the schema: ${extra.join(', ')} (use --strip-unknown-fields to drop them)`, + ) + } + data = stripToSchema(data as Record, props) + canonical = recordCanonical(rec.id, rec.type, data) + } + } + const errs = compileSchema(schema)(data) + if (errs.length > 0) throw new CliError(errs.join('; ')) + const isPrivate = rec.private === true && !isPrivateSchema(schema) + ops.push({ + op: 'put', + type: rec.type, + id: rec.id, + canonical, + ...(isPrivate ? { private: true as const } : {}), + }) + } catch (err) { + if (!(err instanceof CliError || err instanceof InputRuleError)) throw err + if (errors.length < 20) errors.push(`line ${line}: ${err.message}`) + else if (errors.length === 20) errors.push('…') + } + } + if (errors.length > 0) + throw new CliError(`Nothing staged; invalid records:\n ${errors.join('\n ')}`) + local.stageOps(ops) + say(`Staged ${ops.length} record(s)`) + return ops.length +} + +/** Stage deletes of records by type and id. */ +export function rm(local: Local, type: string, ids: string[], say: Say): void { + if (ids.length === 0) throw new CliError('Name at least one record id') + local.stageOps(ids.map((id) => ({ op: 'del' as const, type, id }))) + say(`Staged ${ids.length} delete(s)`) +} + +/** Stage the version metadata from a JSON object file, or clear it. */ +export function metaSet(local: Local, file: string | null, say: Say): void { + if (file === null) { + local.stageMetadata(null) + say('Staged: no metadata') + return + } + const value = JSON.parse(readFileSync(resolve(file), 'utf8')) as unknown + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new CliError('Metadata must be a JSON object') + } + local.stageMetadata(value as Record) + say('Staged metadata') +} + +/** Store files in the local repository, so records can reference them. */ +export async function fileAdd(local: Local, paths: string[], say: Say): Promise { + const out: string[] = [] + for (const p of paths) { + const bytes = new Uint8Array(readFileSync(resolve(p))) + const hash = sha256Hex(bytes) + await local.repo.blobs.put(keys.file(hash), bytes, { ifAbsent: true }) + say(`${p}: {"$file":"sha256:${hash}"}`) + out.push(hash) + } + return out +} diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts new file mode 100644 index 0000000..27240a6 --- /dev/null +++ b/packages/cli/src/commands/sync.ts @@ -0,0 +1,419 @@ +/** + * Moving versions between the local repository and a registry + * (edge-redesign-build.md, "Tree sync"). + * + * pull verify the remote's signed log after what was last seen, then receive + * the newest version as a pack against the last one pulled or pushed + * (receiveVersion re-derives every tree before anything is accepted). + * push the local changes since the last sync, sent as a delta push (upserts + * and deletes) with the files they need; then the registry's new version + * must be the local one. Same hash, or (when the private salt differs) + * the same trees, checked by pulling it back. + */ +import { + compareUtf8, + type DiffEntry, + diffTrees, + fileTree, + isPrivateSchema, + keys, + type PrivateSetObject, + receiveVersion, + type RecordEntry, + recordTree, + RepoSource, + type SetObject, + type SyncSets, + verifyLogEntries, +} from '@underlay/protocol' + +import { Client, type Fetch } from '../client.js' +import { CliError, Local, type LocalVersion, type Tracking } from '../local.js' +import { recordById, versionState, type VersionState } from '../state.js' +import type { Say } from './stage.js' + +const BATCH_LINES = 5000 +const BATCH_BYTES = 8 * 1024 * 1024 +const SMALL_UPLOAD_BYTES = 32 * 1024 * 1024 +const POLL_MS = 1000 + +export interface SyncOptions { + fetch?: Fetch + /** pull: replace local commits that weren't pushed. */ + force?: boolean +} + +// --- pull ------------------------------------------------------------------------ + +export async function pull( + local: Local, + name: string, + opts: SyncOptions, + say: Say, +): Promise { + const cfg = local.remote(name) + const client = new Client(cfg, opts.fetch) + const t = local.tracking(name) + const page = await client.log(t?.seq ?? 0) + if (!page.collection || page.entries.length === 0) { + say(t ? 'Already up to date.' : 'The remote has no versions yet.') + return null + } + const keys = page.collection.keys + const verified = await verifyLogEntries( + page.entries, + keys, + t && { seq: t.seq, entryHash: t.entryHash }, + page.collection.id, + ) + const latest = page.entries[page.entries.length - 1]! + const head = local.headVersion() + if (head && head.hash !== t?.hash && !opts.force) { + throw new CliError( + t + ? `${head.semver} isn't on ${name}; push it first, or pull --force to drop it.` + : `This repository has versions that didn't come from ${name}; pull --force to replace them.`, + ) + } + const want: SyncSets = cfg.token ? 'all' : 'public' + // The last version synced anchors the pack, if this repository holds the sets asked for. + const base = t && (t.sets === 'all' || want === 'public') ? t.hash : null + const pack = await client.pack(latest.versionHash, base, want) + const got = await receiveVersion(local.repo, pack.objects, { + target: latest.versionHash, + base, + sets: pack.sets, + }) + if (pack.sets === 'all' && got.root.private) { + local.setSalt((await local.repo.privateSet(got.root.private)).salt) + } + const version: LocalVersion = { + semver: latest.semver, + hash: latest.versionHash, + baseSemver: latest.baseSemver, + message: latest.message, + createdAt: latest.createdAt, + refs: null, + remote: name, + sets: pack.sets, + } + local.writeVersion(version) + local.setHead(version.semver) + local.setTracking(name, { + seq: latest.seq, + entryHash: verified!.entryHash, + semver: latest.semver, + hash: latest.versionHash, + sets: pack.sets, + keys, + }) + const c = got.changes + say(`Pulled ${latest.semver} from ${name}: +${c.added} ~${c.updated} -${c.removed} record(s)`) + return version +} + +/** `underlay clone [dir]`: init, add the remote as origin, pull. */ +export async function clone( + url: string, + collection: string, + dir: string, + opts: SyncOptions & { token?: string }, + say: Say, +): Promise { + const local = Local.init(dir) + local.setRemotes({ + origin: { url, collection, ...(opts.token ? { token: opts.token } : {}) }, + }) + await pull(local, 'origin', opts, say) + return local +} + +// --- push ------------------------------------------------------------------------ + +type Op = { put: string } | { del: string } + +/** A record's push line: its canonical form, plus the private flag where it matters. */ +function putLine(r: RecordEntry, isPrivate: boolean): string { + return isPrivate ? `${r.body!.slice(0, -1)},"private":true}` : r.body! +} + +/** + * The upserts and deletes that turn `base` into `head`, per type in key order: + * a record present in head is sent with the set it's in; one gone from both + * sets is deleted. + */ +async function* changesToPush( + local: Local, + base: VersionState | null, + head: VersionState, +): AsyncGenerator { + const repo = local.repo + const source = new RepoSource(recordTree, repo) + for (const [slug, schema] of Object.entries(head.schemas)) { + const privateType = isPrivateSchema(schema) + const pubDiff = diffTrees( + source, + base?.public.types[slug]?.root ?? null, + head.public.types[slug]?.root ?? null, + )[Symbol.asyncIterator]() + const privDiff = diffTrees( + source, + base?.private.types[slug]?.root ?? null, + head.private.types[slug]?.root ?? null, + )[Symbol.asyncIterator]() + let a = await pubDiff.next() + let b = await privDiff.next() + while (!a.done || !b.done) { + const ka = a.done ? null : a.value.key + const kb = b.done ? null : b.value.key + const key = ka === null ? kb! : kb === null ? ka : compareUtf8(ka, kb) <= 0 ? ka : kb + const pd: DiffEntry | null = ka === key ? a.value! : null + const vd: DiffEntry | null = kb === key ? b.value! : null + if (pd) a = await pubDiff.next() + if (vd) b = await privDiff.next() + if (pd?.after) { + const r = await recordById(repo, head.public.types[slug]!.root, key) + yield { put: putLine(r!, false) } + } else if (vd?.after) { + const r = await recordById(repo, head.private.types[slug]!.root, key) + yield { put: putLine(r!, !privateType) } + } else { + yield { del: JSON.stringify({ type: slug, id: key }) } + } + } + } +} + +/** Files the head's sets have that the base's don't, which the registry may lack. */ +async function newFiles(local: Local, base: VersionState | null, head: VersionState) { + const out = new Map() + const source = new RepoSource(fileTree, local.repo) + const pairs: [SetObject | null, SetObject][] = [ + [base?.public ?? null, head.public], + [base?.private ?? null, head.private], + ] + for (const [b, h] of pairs) { + for await (const d of diffTrees(source, b?.files.root ?? null, h.files.root)) { + if (d.after) out.set(d.key, d.after.size) + } + } + return out +} + +async function uploadFiles(local: Local, client: Client, files: Map, say: Say) { + for (const [hash, size] of files) { + const obj = await local.repo.blobs.get(keys.file(hash)) + if (!obj) + throw new CliError(`File ${hash} isn't in this repository; add it with \`underlay file add\``) + if (size <= SMALL_UPLOAD_BYTES) { + const res = await client.request(`/files/${hash}`, { + method: 'PUT', + headers: { 'content-type': 'application/octet-stream' }, + body: (await obj.bytes()) as BodyInit, + }) + if (!res.ok) throw new CliError(`Uploading file ${hash}: ${res.status}`) + continue + } + // Large: a direct upload to storage, then wait for the registry to verify it. + type Part = { partNumber: number; url: string } + const ticket = await client.post<{ + id: string + url?: string + partBytes?: number + partCount?: number + parts?: Part[] + }>('/files/uploads', { hash, size }) + const fetchRaw = client.fetchImpl + const parts: { partNumber: number; etag: string }[] = [] + if (ticket.url) { + const res = await fetchRaw(ticket.url, { + method: 'PUT', + body: (await obj.bytes()) as BodyInit, + }) + if (!res.ok) throw new CliError(`Uploading file ${hash}: ${res.status}`) + } else { + const partBytes = ticket.partBytes ?? Math.ceil(size / ticket.parts!.length) + const partCount = ticket.partCount ?? ticket.parts!.length + // The ticket presigns the first page of parts; the rest come a page at a time. + let page = ticket.parts! + for (let n = 1; n <= partCount; n++) { + if (!page.some((x) => x.partNumber === n)) { + page = ( + await client.json<{ parts: Part[] }>(`/files/uploads/${ticket.id}/parts?from=${n}`) + ).parts + } + const p = page.find((x) => x.partNumber === n) + if (!p) throw new CliError(`No upload URL for part ${n} of ${hash}`) + const offset = (p.partNumber - 1) * partBytes + const chunk = await local.repo.blobs.get(keys.file(hash), { offset, length: partBytes }) + const res = await fetchRaw(p.url, { + method: 'PUT', + body: (await chunk!.bytes()) as BodyInit, + }) + if (!res.ok) throw new CliError(`Uploading part ${p.partNumber} of ${hash}: ${res.status}`) + parts.push({ partNumber: p.partNumber, etag: res.headers.get('etag') ?? '' }) + } + } + await client.post(`/files/uploads/${ticket.id}/complete`, parts.length > 0 ? { parts } : {}) + for (;;) { + const s = await client.json<{ status: string; error?: string }>(`/files/uploads/${ticket.id}`) + if (s.status === 'verified') break + if (s.status === 'failed') + throw new CliError(`File ${hash} failed verification: ${s.error ?? ''}`) + await new Promise((r) => setTimeout(r, POLL_MS)) + } + } + if (files.size > 0) say(`Uploaded ${files.size} file(s)`) +} + +/** Send ops in batches under the registry's request limits. */ +async function sendOps(client: Client, sid: string, ops: AsyncIterable) { + const pending = { put: [] as string[], del: [] as string[] } + const bytes = { put: 0, del: 0 } + const counts = { put: 0, del: 0 } + const flush = async (kind: 'put' | 'del') => { + if (pending[kind].length === 0) return + await client.postNdjson(`/push/${sid}/${kind === 'put' ? 'records' : 'deletes'}`, pending[kind]) + counts[kind] += pending[kind].length + pending[kind] = [] + bytes[kind] = 0 + } + for await (const op of ops) { + const kind = 'put' in op ? 'put' : 'del' + const line = 'put' in op ? op.put : op.del + pending[kind].push(line) + bytes[kind] += line.length + 1 + if (pending[kind].length >= BATCH_LINES || bytes[kind] >= BATCH_BYTES) await flush(kind) + } + await flush('put') + await flush('del') + return counts +} + +/** Same records, files and metadata: what a push must reproduce. */ +function sameContent(a: VersionState, b: VersionState, sets: SyncSets): boolean { + const pick = (s: VersionState) => ({ + metadata: s.root.metadata, + public: s.public, + private: sets === 'all' ? { types: s.private.types, files: s.private.files } : null, + }) + return JSON.stringify(pick(a)) === JSON.stringify(pick(b)) +} + +export async function push( + local: Local, + name: string, + opts: SyncOptions, + say: Say, +): Promise { + const cfg = local.remote(name) + const client = new Client(cfg, opts.fetch) + const head = local.headVersion() + if (!head) throw new CliError('Nothing to push: commit something first.') + const t: Tracking | null = local.tracking(name) + if (t && head.hash === t.hash) { + say('Everything up to date.') + return head + } + const remote = await client.log(t?.seq ?? 0) + if (remote.entries.length > 0) { + throw new CliError(`${name} has versions this repository hasn't pulled. Pull first.`) + } + const tracked = t ? local.version(t.semver) : null + const baseState = tracked ? await versionState(local, tracked) : null + const headState = await versionState(local, head) + const sets: SyncSets = t?.sets ?? 'all' + + await uploadFiles(local, client, await newFiles(local, baseState, headState), say) + const session = await client.post<{ session_id: string }>('/push', { + base: t?.semver ?? null, + schemas: headState.schemas, + metadata: headState.root.metadata, + message: head.message, + }) + const sid = session.session_id + const sent = await sendOps(client, sid, changesToPush(local, baseState, headState)) + + const res = await client.request(`/push/${sid}/commit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }) + let result = (await res.json()) as { semver?: string; hash?: string; error?: string } + if (res.status === 202) { + for (;;) { + await new Promise((r) => setTimeout(r, POLL_MS)) + const s = await client.json<{ + status: string + result: { semver: string; hash: string } | null + error: { error?: string } | null + }>(`/push/${sid}`) + if (s.status === 'committed') { + result = s.result! + break + } + if (s.status !== 'committing') + throw new CliError(`Push failed: ${s.error?.error ?? s.status}`) + } + } else if (res.status !== 201) { + throw new CliError(`Push failed: ${res.status} ${result.error ?? ''}`) + } + + // The registry's log now ends with the new version. + const after = await client.log(t?.seq ?? 0) + const verified = await verifyLogEntries( + after.entries, + after.collection!.keys, + t && { seq: t.seq, entryHash: t.entryHash }, + after.collection!.id, + ) + const entry = after.entries[after.entries.length - 1] + if (!entry || entry.versionHash !== result.hash) { + throw new CliError('The registry committed, but its log does not end with the new version') + } + if (result.hash !== head.hash) { + // Typically the private salt: the registry's differs from a local one. Pull + // its version back and compare content, not hashes. + const pack = await client.pack(result.hash!, t?.hash ?? null, sets) + const got = await receiveVersion(local.repo, pack.objects, { + target: result.hash!, + base: t?.hash ?? null, + sets: pack.sets, + }) + const theirs = await versionState(local, { + ...head, + hash: result.hash!, + sets: pack.sets, + }) + if (!sameContent(theirs, headState, pack.sets)) { + throw new CliError( + `${name} built ${result.semver} (${result.hash}) with different content from ${head.semver}`, + ) + } + if (got.root.private && pack.sets === 'all') { + local.setSalt(((await local.repo.privateSet(got.root.private)) as PrivateSetObject).salt) + } + } + const version: LocalVersion = { + semver: result.semver!, + hash: result.hash!, + baseSemver: t?.semver ?? null, + message: head.message, + createdAt: entry.createdAt, + refs: result.hash === head.hash ? head.refs : null, + remote: name, + sets, + } + local.writeVersion(version) + local.setHead(version.semver) + local.setTracking(name, { + seq: entry.seq, + entryHash: verified!.entryHash, + semver: version.semver, + hash: version.hash, + sets, + keys: after.collection!.keys, + }) + say(`Pushed ${version.semver} to ${name}: ${sent.put} upsert(s), ${sent.del} delete(s)`) + return version +} diff --git a/packages/cli/src/local.ts b/packages/cli/src/local.ts new file mode 100644 index 0000000..7f135a7 --- /dev/null +++ b/packages/cli/src/local.ts @@ -0,0 +1,243 @@ +/** + * The local repository: `.underlay/` in a working directory. + * + * repo/ a repository in the protocol layout (fileStore): nodes, + * bodies, roots, schemas, private set objects, files + * HEAD the current local version's semver (empty before the first) + * versions/.json local versions (LocalVersion) + * salt the private-set salt (the registry's, once pulled) + * staging/schemas.json staged type schemas (the full new type set) + * staging/metadata.json staged metadata + * staging/ops.ndjson staged upserts and deletes, in order (the last per record wins) + * config.json remotes + * remotes/.json what was last pulled from or pushed to a remote (Tracking) + */ +import { + appendFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' + +import { + compareSemver, + fileStore, + newSalt, + openRepo, + type PublicKeyInfo, + type Repo, +} from '@underlay/protocol' + +export const DIR = '.underlay' + +export class CliError extends Error { + constructor(message: string) { + super(message) + this.name = 'CliError' + } +} + +export interface LocalVersion { + semver: string + hash: string + baseSemver: string | null + message: string | null + createdAt: string + /** + * The file reference count trees (writer bookkeeping, not in the version + * itself). Null when unknown, as for a pulled version; rebuilt on demand. + */ + refs: { public: string | null; private: string | null } | null + /** Set when this version came from, or was confirmed by, a remote. */ + remote?: string + /** The sets this repository holds of the version. */ + sets: 'public' | 'all' +} + +export interface RemoteConfig { + url: string + /** owner/slug */ + collection: string + token?: string +} + +export interface Tracking { + seq: number + entryHash: string + semver: string + hash: string + sets: 'public' | 'all' + /** The keys the remote's log was verified with. */ + keys: PublicKeyInfo[] +} + +/** One staged operation: an upsert (canonical record) or a delete. */ +export type StagedOp = + | { op: 'put'; type: string; id: string; canonical: string; private?: true } + | { op: 'del'; type: string; id: string } + +const readJson = (path: string): T | null => + existsSync(path) ? (JSON.parse(readFileSync(path, 'utf8')) as T) : null +const writeJson = (path: string, value: unknown) => { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2) + '\n') +} + +export class Local { + readonly dir: string + #repo: Repo | undefined + + constructor(readonly root: string) { + this.dir = join(root, DIR) + } + + /** The repository containing `start`, or null. */ + static find(start = process.cwd()): Local | null { + let dir = resolve(start) + for (;;) { + if (existsSync(join(dir, DIR))) return new Local(dir) + const parent = dirname(dir) + if (parent === dir) return null + dir = parent + } + } + + static require(start = process.cwd()): Local { + const local = Local.find(start) + if (!local) throw new CliError('Not an Underlay repository. Run `underlay init` first.') + return local + } + + static init(dir: string): Local { + const local = new Local(resolve(dir)) + if (existsSync(local.dir)) throw new CliError(`${local.dir} already exists`) + mkdirSync(join(local.dir, 'repo'), { recursive: true }) + mkdirSync(join(local.dir, 'versions'), { recursive: true }) + mkdirSync(join(local.dir, 'staging'), { recursive: true }) + writeFileSync(join(local.dir, 'HEAD'), '') + writeFileSync(join(local.dir, 'salt'), newSalt()) + writeJson(join(local.dir, 'config.json'), { remotes: {} }) + return local + } + + /** The local objects. Trusted: everything in it was verified on the way in. */ + get repo(): Repo { + return (this.#repo ??= openRepo(fileStore(join(this.dir, 'repo')), { trusted: true })) + } + + // --- Versions --- + + head(): string | null { + const s = readFileSync(join(this.dir, 'HEAD'), 'utf8').trim() + return s || null + } + + setHead(semver: string): void { + writeFileSync(join(this.dir, 'HEAD'), semver) + } + + headVersion(): LocalVersion | null { + const h = this.head() + return h ? this.version(h) : null + } + + version(semver: string): LocalVersion | null { + return readJson(join(this.dir, 'versions', `${semver}.json`)) + } + + writeVersion(v: LocalVersion): void { + writeJson(join(this.dir, 'versions', `${v.semver}.json`), v) + } + + versions(): LocalVersion[] { + const dir = join(this.dir, 'versions') + return readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => readJson(join(dir, f))!) + .sort((a, b) => compareSemver(a.semver, b.semver)) + } + + salt(): string { + return readFileSync(join(this.dir, 'salt'), 'utf8').trim() + } + + setSalt(salt: string): void { + writeFileSync(join(this.dir, 'salt'), salt) + } + + // --- Staging --- + + stagedSchemas(): Record> | null { + return readJson(join(this.dir, 'staging', 'schemas.json')) + } + + stageSchemas(schemas: Record>): void { + writeJson(join(this.dir, 'staging', 'schemas.json'), schemas) + } + + /** Staged metadata: `undefined` when none is staged (null is a staged clear). */ + stagedMetadata(): Record | null | undefined { + const v = readJson<{ metadata: Record | null }>( + join(this.dir, 'staging', 'metadata.json'), + ) + return v ? v.metadata : undefined + } + + stageMetadata(metadata: Record | null): void { + writeJson(join(this.dir, 'staging', 'metadata.json'), { metadata }) + } + + stagedOps(): StagedOp[] { + const path = join(this.dir, 'staging', 'ops.ndjson') + if (!existsSync(path)) return [] + return readFileSync(path, 'utf8') + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as StagedOp) + } + + stageOps(ops: StagedOp[]): void { + if (ops.length === 0) return + mkdirSync(join(this.dir, 'staging'), { recursive: true }) + appendFileSync( + join(this.dir, 'staging', 'ops.ndjson'), + ops.map((o) => JSON.stringify(o)).join('\n') + '\n', + ) + } + + clearStaging(): void { + rmSync(join(this.dir, 'staging'), { recursive: true, force: true }) + mkdirSync(join(this.dir, 'staging'), { recursive: true }) + } + + // --- Remotes --- + + remotes(): Record { + return readJson<{ remotes: Record }>(join(this.dir, 'config.json'))! + .remotes + } + + setRemotes(remotes: Record): void { + writeJson(join(this.dir, 'config.json'), { remotes }) + } + + remote(name: string): RemoteConfig { + const r = this.remotes()[name] + if (!r) throw new CliError(`No remote named "${name}". Add one with \`underlay remote add\`.`) + return r + } + + tracking(name: string): Tracking | null { + return readJson(join(this.dir, 'remotes', `${name}.json`)) + } + + setTracking(name: string, t: Tracking | null): void { + const path = join(this.dir, 'remotes', `${name}.json`) + if (t) writeJson(path, t) + else rmSync(path, { force: true }) + } +} diff --git a/packages/cli/src/state.ts b/packages/cli/src/state.ts new file mode 100644 index 0000000..627029e --- /dev/null +++ b/packages/cli/src/state.ts @@ -0,0 +1,57 @@ +/** Reading a local version back: its root, its sets, its schemas and its records. */ +import { + compareUtf8, + emptySet, + type PrivateSetObject, + type RecordEntry, + recordTree, + type Repo, + RepoSource, + type SetObject, + type VersionRoot, +} from '@underlay/protocol' + +import type { Local, LocalVersion } from './local.js' + +export interface VersionState { + version: LocalVersion + root: VersionRoot + public: SetObject + /** Empty when the version has no private set or this repository doesn't hold it. */ + private: PrivateSetObject | SetObject + /** Every type's schema, from whichever set holds the type. */ + schemas: Record> +} + +export async function versionState(local: Local, v: LocalVersion): Promise { + const repo = local.repo + const root = await repo.root(v.hash) + const priv = v.sets === 'all' && root.private ? await repo.privateSet(root.private) : emptySet() + const schemas: Record> = {} + for (const set of [root.public, priv]) { + for (const [slug, t] of Object.entries(set.types)) { + schemas[slug] ??= await repo.schema(t.schema) + } + } + return { version: v, root, public: root.public, private: priv, schemas } +} + +/** A record of a tree, with its body, by id. O(height). */ +export async function recordById( + repo: Repo, + root: string | null, + id: string, +): Promise { + if (!root) return null + const source = new RepoSource(recordTree, repo) + let hash = root + for (;;) { + const node = await source.node(hash) + if (node.kind === 'leaf') { + return (await source.leafEntries(hash)).find((e) => e.key === id) ?? null + } + const child = node.children.find((c) => compareUtf8(id, c.lastKey) <= 0) + if (!child) return null + hash = child.hash + } +} diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts new file mode 100644 index 0000000..db5abf6 --- /dev/null +++ b/packages/cli/test/cli.test.ts @@ -0,0 +1,248 @@ +import { mkdtemp, rm as rmDir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../../server/src/db/schema.js' +import { cleanup as cleanupHarness, harness, type Harness } from '../../server/test/harness.js' +import { commit } from '../src/commands/commit.js' +import { diff, fsck, log, remoteAdd, status } from '../src/commands/info.js' +import { add, fileAdd, metaSet, rm, schemaSet } from '../src/commands/stage.js' +import { clone, pull, push } from '../src/commands/sync.js' +import { CliError, Local } from '../src/local.js' +import { versionState } from '../src/state.js' + +const dirs: string[] = [] +afterAll(async () => { + for (const d of dirs.splice(0)) await rmDir(d, { recursive: true, force: true }) + await cleanupHarness() +}) + +async function tmp() { + const d = await mkdtemp(join(tmpdir(), 'ul-cli-')) + dirs.push(d) + return d +} + +const quiet = () => { + const lines: string[] = [] + return Object.assign((l: string) => void lines.push(l), { lines }) +} + +const Book = { + type: 'object', + properties: { title: { type: 'string' }, cover: { type: 'object' } }, + required: ['title'], +} +const Note = { type: 'object', properties: { text: { type: 'string' } }, private: true } + +async function writeNdjson(dir: string, name: string, rows: unknown[]) { + const path = join(dir, name) + await writeFile(path, rows.map((r) => JSON.stringify(r)).join('\n') + '\n') + return path +} + +/** Route registry requests to an in-process app; a token stands for the member. */ +function registry(h: Harness, user: string) { + return async (url: string, init: RequestInit = {}) => { + if (url.startsWith('memory://')) throw new Error(`unexpected presigned URL ${url}`) + const headers = new Headers(init.headers) + if (headers.get('authorization')) headers.set('x-test-user', user) + headers.delete('authorization') + return h.app.fetch( + new Request(url.replace(/^https?:\/\/[^/]+/, 'http://test'), { ...init, headers }), + ) + } +} + +async function contents(local: Local) { + const s = await versionState(local, local.headVersion()!) + return { metadata: s.root.metadata, public: s.public, private: s.private.types } +} + +describe('offline', () => { + it('stages, validates and commits versions, and diffs them', async () => { + const dir = await tmp() + const local = Local.init(dir) + const say = quiet() + await writeFile(join(dir, 'schemas.json'), JSON.stringify({ Book, Note })) + await schemaSet(local, join(dir, 'schemas.json'), say) + const bad = await writeNdjson(dir, 'bad.ndjson', [ + { id: 'b1', type: 'Book', data: { title: 5 } }, + ]) + await expect(add(local, bad, {}, say)).rejects.toThrow(/Nothing staged; invalid records/) + const books = await writeNdjson(dir, 'books.ndjson', [ + ...Array.from({ length: 3000 }, (_, i) => ({ + id: `b${i}`, + type: 'Book', + data: { title: `T${i}` }, + })), + { id: 'secret', type: 'Book', data: { title: 'hidden' }, private: true }, + { id: 'n1', type: 'Note', data: { text: 'private type' } }, + ]) + expect(await add(local, books, {}, say)).toBe(3002) + const v1 = await commit(local, 'first', say) + expect(v1.semver).toBe('v1.0.0') + const s1 = await versionState(local, v1) + expect(s1.public.types.Book!.count).toBe(3000) + expect(s1.private.types.Book!.count).toBe(1) + expect(s1.private.types.Note!.count).toBe(1) + + // A file must be in the repository before a record can reference it. + await writeFile(join(dir, 'cover.png'), 'png bytes') + const withCover = await writeNdjson(dir, 'c.ndjson', [ + { + id: 'b7', + type: 'Book', + data: { title: 'T7', cover: { $file: `sha256:${'a'.repeat(64)}` } }, + }, + ]) + await add(local, withCover, {}, say) + await expect(commit(local, 'missing file', say)).rejects.toThrow(/underlay file add/) + local.clearStaging() + const [hash] = await fileAdd(local, [join(dir, 'cover.png')], say) + const ok = await writeNdjson(dir, 'c2.ndjson', [ + { id: 'b7', type: 'Book', data: { title: 'T7', cover: { $file: `sha256:${hash}` } } }, + ]) + await add(local, ok, {}, say) + rm(local, 'Book', ['b1', 'b2'], say) + await writeFile(join(dir, 'meta.json'), JSON.stringify({ title: 'Books' })) + metaSet(local, join(dir, 'meta.json'), say) + const v2 = await commit(local, 'second', say) + expect(v2.semver).toBe('v1.1.0') + const s2 = await versionState(local, v2) + expect(s2.public.types.Book!.count).toBe(2998) + expect(s2.public.files.count).toBe(1) + expect(s2.root.metadata).toEqual({ title: 'Books' }) + + const out = quiet() + await diff(local, 'v1.0.0', 'v1.1.0', out) + expect(out.lines.join('\n')).toMatch(/Book: \+0 ~1 -2/) + log(local, out) + status(local, out) + expect(out.lines.join('\n')).toMatch(/Nothing staged/) + await expect(commit(local, 'again', say)).rejects.toThrow(/Nothing staged/) + }) +}) + +describe('with a registry', () => { + async function setup() { + const h = await harness() + const user = await h.member() + await h.collection('books') + const fetch = registry(h, user) + const dir = await tmp() + const local = Local.init(dir) + const say = quiet() + remoteAdd( + local, + 'origin', + 'https://registry.test', + { collection: 'org/books', token: 'k' }, + say, + ) + await writeFile(join(dir, 'schemas.json'), JSON.stringify({ Book, Note })) + await schemaSet(local, join(dir, 'schemas.json'), say) + await add( + local, + await writeNdjson(dir, 'books.ndjson', [ + ...Array.from({ length: 1200 }, (_, i) => ({ + id: `b${i}`, + type: 'Book', + data: { title: `T${i}` }, + })), + { id: 'secret', type: 'Book', data: { title: 'hidden' }, private: true }, + { id: 'n1', type: 'Note', data: { text: 'note' } }, + ]), + {}, + say, + ) + await commit(local, 'first', say) + return { h, user, fetch, dir, local, say } + } + + it('pushes, then clones and round-trips between two repositories', async () => { + const { h, user, fetch, dir, local, say } = await setup() + const before = await contents(local) + // The registry's private salt isn't this repository's: the push is checked by + // content, and the registry's version (and salt) adopted. + const pushed = await push(local, 'origin', { fetch }, say) + expect(pushed!.semver).toBe('v1.0.0') + expect(await contents(local)).toEqual({ ...before, private: before.private }) + expect(local.headVersion()!.hash).toBe(pushed!.hash) + const api = await h.request('/api/collections/org/books/versions/latest', { user }) + expect(((await api.json()) as { hash: string }).hash).toBe(pushed!.hash) + + // A second repository, cloned with a token, gets the private sets too. + const dir2 = join(await tmp(), 'clone') + const other = await clone( + 'https://registry.test', + 'org/books', + dir2, + { fetch, token: 'k' }, + say, + ) + expect(await contents(other)).toEqual(await contents(local)) + // The clone checks out (a clone verifies the log over the API and keeps none). + const lines: string[] = [] + await fsck(other, { files: true }, (l) => void lines.push(l)) + expect(lines).toEqual([expect.stringMatching(/^local versions: ok \(1 versions/)]) + + // Change it there, with the registry's salt now known: hashes match exactly. + await writeFile( + join(dir2, 'more.ndjson'), + JSON.stringify({ id: 'b9999', type: 'Book', data: { title: 'new' } }) + '\n', + ) + await add(other, join(dir2, 'more.ndjson'), {}, say) + rm(other, 'Book', ['b3'], say) + const localV2 = await commit(other, 'from the clone', say) + const v2 = await push(other, 'origin', { fetch }, say) + expect(v2!.hash).toBe(localV2.hash) + + // The first repository can't push past it, and pulls it. + await writeFile( + join(dir, 'x.ndjson'), + JSON.stringify({ id: 'x', type: 'Book', data: { title: 'x' } }) + '\n', + ) + await add(local, join(dir, 'x.ndjson'), {}, say) + await commit(local, 'diverged', say) + await expect(push(local, 'origin', { fetch }, say)).rejects.toThrow(/Pull first/) + await expect(pull(local, 'origin', { fetch }, say)).rejects.toThrow(/push it first/) + const got = await pull(local, 'origin', { fetch, force: true }, say) + expect(got!.hash).toBe(v2!.hash) + expect(await contents(local)).toEqual(await contents(other)) + + // A body gone from disk: fsck says so. + const { readdir, rm: rmFile } = await import('node:fs/promises') + const bodyDir = join(dir2, '.underlay', 'repo', 'bodies') + const [body] = await readdir(bodyDir) + await rmFile(join(bodyDir, body!)) + const out: string[] = [] + await expect(fsck(other, {}, (l) => void out.push(l))).rejects.toThrow(/fsck found problems/) + expect(out.join('\n')).toMatch(/PROBLEMS/) + }) + + it('clones without a token: public sets only', async () => { + const { h, fetch, local, say } = await setup() + await push(local, 'origin', { fetch }, say) + const anon = async (url: string, init: RequestInit = {}) => { + const headers = new Headers(init.headers) + headers.delete('authorization') + return fetch(url, { ...init, headers }) + } + const dir2 = join(await tmp(), 'public') + // The collection is private until made public. + await expect( + clone('https://registry.test', 'org/books', dir2, { fetch: anon }, say), + ).rejects.toThrow(CliError) + await h.ports.db.update(schema.collections).set({ public: true }) + const pub = await clone('https://registry.test', 'org/books', `${dir2}-2`, { fetch: anon }, say) + expect(pub.headVersion()!.sets).toBe('public') + const mine = await contents(local) + const theirs = await contents(pub) + expect(theirs.public).toEqual(mine.public) + expect(theirs.private).toEqual({}) + expect(theirs.public.types.Book!.count).toBe(1200) + }) +}) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..8c8d80a --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "target": "ES2024", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..3e42797 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}) diff --git a/packages/migrate/README.md b/packages/migrate/README.md new file mode 100644 index 0000000..a93c467 --- /dev/null +++ b/packages/migrate/README.md @@ -0,0 +1,97 @@ +# @underlay/migrate + +v1 (Postgres) → v2 conversion, and the tools around loading the result into a +v2 deployment. Background, decisions and run history: the meta repo's +`planning/kf/underlay/edge-redesign-build.md`, sections "Deployment targets" and +"Fixes after the alignment review". + +## Tools + +| | | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/main.ts` | The converter: v1 database → a v2 SQLite file plus repository objects in the target bucket, then (with `V1_S3_*`) copies file bytes to their v2 keys. Its header lists the environment. | +| `src/convert.ts` | The conversion itself: accounts, then each collection's versions replayed oldest first through `commitVersion`, then collection settings. | +| `src/ssh-psql.ts` | Reads a v1 database with no reachable port through `ssh … docker exec … psql` (read-only, cursors for ordered reads). | +| `src/files.ts` | Copies v1 file objects to `/files/`, hash-checked, re-runnable. | +| `src/d1-data.ts` | Writes a SQLite file's rows (or named tables) as SQL for `wrangler d1 execute --file`, parents before children. | +| `src/repair.ts` | Brings an already-loaded deployment up to the current format without converting again: rewrites version logs, records file possession, rebuilds head count trees (`STEPS`). | +| `scripts/staging-env.sh` | Environment for dev → staging (decrypts secrets into the shell only). | +| `scripts/dev-to-staging.sh` | Runs the converter for dev → staging. | +| `scripts/prod-env.sh` | Environment for v1 production → prod (www.underlay.org). | +| `scripts/v1-prod-to-prod.sh` | Runs the converter for v1 production → prod. | + +## dev → staging + +From the repo root, with `sops`, the age key for the env files and `~/.ssh/kf_internal`: + +```sh +packages/migrate/scripts/dev-to-staging.sh /tmp/ul-load # COLLECTIONS=owner/slug,… for some +npx tsx packages/migrate/src/d1-data.ts /tmp/ul-load/migrated.sqlite > /tmp/ul-load/data.sql +cd packages/server +npx wrangler d1 migrations apply underlay-staging --env staging --remote +npx wrangler d1 execute underlay-staging --env staging --remote --file /tmp/ul-load/data.sql +``` + +Load into an empty D1. Before re-converting collections that are already in the +bucket, delete their `repo/collections//` objects (logs, head, collection.json); +content-addressed objects can stay. Keep output outside Dropbox: the SQLite file +holds dev's users and sessions. + +To repair a loaded deployment, `repair.ts` needs a SQLite copy of its database: +`wrangler d1 export underlay-staging --env staging --remote --output d1.sql`, then +`sqlite3 d1.sqlite < d1.sql`, then `TARGET_DB=file:d1.sqlite TARGET_DB_MIGRATE=0` (the +export already has the schema, without drizzle's bookkeeping) with +`. packages/migrate/scripts/staging-env.sh`. Load the rows it adds with +`d1-data.ts d1.sqlite `. + +`STEPS` picks what `repair.ts` does (default `logs,possession`). `STEPS=refs` rebuilds +each collection head's file reference count trees with per-type counts (2026-10-04), +so moving or removing a type stops reading its records; heads whose trees already +have them, or have none, are skipped. It writes `UPDATE versions …` lines to +`REFS_SQL` (default `./refs.sql`); apply them with `wrangler d1 execute … --file`. +Without it nothing breaks: older trees take the slower path. + +**Pause storage cleanup first.** These tools write objects to the bucket now and their +rows land in D1 later, outside the write fence, so a sweep in between would delete what +they wrote. On a deployment that has storage cleanup (migration 0015), switch on "Pause +marks and sweeps" at `/admin/cleanup` (or set `cleanup_paused` in `instance_settings`) +before converting or repairing, and switch it off once every row is loaded. + +## v1 production → prod + +The same steps with `scripts/v1-prod-to-prod.sh`, `prod-env.sh`, database `underlay-prod` +and `--env prod`. It reads production's Postgres (`underlay-prod_postgres`) for the whole +run. Before the first load, check production for duplicate ids (the report lists the ones +dropped) and for org slugs now reserved (`RESERVED_ORG_SLUGS` in +`packages/server/src/lib/slug.ts`). + +Once loaded, keep the output folder: later runs bring the same database up to date +instead of starting again (`SYNC=1`, the header of `src/convert.ts`). Each sync replays +only versions newer than each collection's head, mirrors account rows (deletes too), and +`d1-data.ts --since` writes just the rows that changed since the copy last loaded: + +```sh +OUT=~/ul-prod-load +# right after a load: keep a copy of what D1 now holds +sqlite3 $OUT/migrated.sqlite ".backup $OUT/loaded.sqlite" +# any time later, as often as needed +SYNC=1 packages/migrate/scripts/v1-prod-to-prod.sh $OUT +npx tsx packages/migrate/src/d1-data.ts $OUT/migrated.sqlite --since $OUT/loaded.sqlite > $OUT/delta.sql +(cd packages/server && npx wrangler d1 execute underlay-prod --env prod --remote --file $OUT/delta.sql) +sqlite3 $OUT/migrated.sqlite ".backup $OUT/loaded.sqlite" +``` + +- The bucket moves forward with `migrated.sqlite`: never convert from scratch into it + again, or delete its `repo/collections/`, without also emptying D1 and starting over. +- If a sync fails partway, run it again: it resumes from each collection's head. + Don't put back an older `migrated.sqlite`; its heads would be behind the bucket's. +- Nothing may push to the prod deployment between syncs: its collection rows have to stay + as the last load left them. Sign-ins are fine (their rows aren't in either file). +- Collections v1 deleted are listed in the report's `removedInV1` and left in place. +- At the switch: freeze writes on www, sync once more, load the delta, then move DNS. + After the switch v1 is no longer the source, so don't sync again. + +## Tests + +`npx vitest run --root packages/migrate`: the converter against v1's own +migrations in PGlite (both read paths), and the file copy. diff --git a/packages/migrate/package.json b/packages/migrate/package.json new file mode 100644 index 0000000..ff76234 --- /dev/null +++ b/packages/migrate/package.json @@ -0,0 +1,25 @@ +{ + "name": "@underlay/migrate", + "version": "0.0.0", + "private": true, + "description": "Convert an Underlay v1 (Postgres) instance into v2: accounts and settings into SQLite, every collection's version history through the v2 commit engine into a repository.", + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@libsql/client": "^0.18.0", + "@underlay/protocol": "workspace:*", + "@underlay/server": "workspace:*", + "drizzle-orm": "^0.45.2", + "postgres": "^3.4.9" + }, + "devDependencies": { + "@electric-sql/pglite": "^0.5.8", + "@types/node": "^25.0.0", + "tsx": "^4.19.0", + "typescript": "^6.0.0", + "vitest": "^4.1.6" + } +} diff --git a/packages/migrate/scripts/dev-to-staging.sh b/packages/migrate/scripts/dev-to-staging.sh new file mode 100755 index 0000000..b04b01a --- /dev/null +++ b/packages/migrate/scripts/dev-to-staging.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Convert dev's v1 data for staging (edge-redesign-build.md, "Deployment targets"). +# +# packages/migrate/scripts/dev-to-staging.sh [OUT] from the repo root +# COLLECTIONS=owner/slug,… packages/migrate/scripts/dev-to-staging.sh +# +# Writes repository objects to the underlay-staging bucket, and migrated.sqlite, +# report.json and migrate.log to OUT (default: a new temp directory, outside +# Dropbox: the SQLite file holds dev's users and sessions). Then load it: +# +# npx tsx packages/migrate/src/d1-data.ts $OUT/migrated.sqlite > $OUT/data.sql +# cd packages/server && npx wrangler d1 execute underlay-staging --env staging --remote --file $OUT/data.sql +# +# Into an empty D1 with migrations applied (d1-data.ts says why). A full run took +# 3 h 19 min before each type was read once (5fb2bb1); expect well under two hours. +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +OUT=${1:-$(mktemp -d -t ul-dev-to-staging)} +mkdir -p "$OUT" +. packages/migrate/scripts/staging-env.sh +export TARGET_DB=file:$OUT/migrated.sqlite +echo "[dev-to-staging] writing to $OUT" >&2 +NODE_OPTIONS=--max-old-space-size=8192 npx tsx packages/migrate/src/main.ts \ + > "$OUT/report.json" 2> >(tee "$OUT/migrate.log" >&2) diff --git a/packages/migrate/scripts/prod-env.sh b/packages/migrate/scripts/prod-env.sh new file mode 100644 index 0000000..47893d6 --- /dev/null +++ b/packages/migrate/scripts/prod-env.sh @@ -0,0 +1,24 @@ +# Environment for converting production's v1 data into prod (www.underlay.org), or +# repairing it (edge-redesign-build.md, "Deployment targets"). Source it from the repo root: +# +# . packages/migrate/scripts/prod-env.sh +# +# Secrets are decrypted from .env.prod-v2.enc and .env.prod.enc (SOPS) into this +# shell's environment only; nothing is written to disk. The v1 source is production's +# Postgres, read over SSH (src/ssh-psql.ts), and v1's file bucket. + +_ul_dotenv() { sops -d --input-type dotenv --output-type dotenv "$1" | grep -E "^($2)="; } +eval "$(_ul_dotenv .env.prod-v2.enc 'SIGNING_KEY|R2_ACCESS_KEY_ID|R2_SECRET_ACCESS_KEY' | sed 's/^/export /')" +eval "$(_ul_dotenv .env.prod.enc 'S3_ACCESS_KEY|S3_SECRET_KEY' | sed 's/^S3_/export V1_S3_/')" +unset -f _ul_dotenv + +_ul_r2=https://b66a542ad5e0af992703a2ce8d1d8747.r2.cloudflarestorage.com +# Target: prod's bucket. Source: v1's file bucket (shared by v1 dev and prod; read only). +export S3_ENDPOINT=$_ul_r2 S3_BUCKET=underlay-prod +export S3_ACCESS_KEY=$R2_ACCESS_KEY_ID S3_SECRET_KEY=$R2_SECRET_ACCESS_KEY +export V1_S3_ENDPOINT=$_ul_r2 V1_S3_BUCKET=underlay-assets +unset _ul_r2 +# Production's Postgres. The same box runs underlay-dev_postgres and underlay-mirror_postgres: +# ssh-psql refuses any container whose name doesn't start with "$V1_CONTAINER.". +export V1_SSH="-i $HOME/.ssh/kf_internal -o BatchMode=yes -o ServerAliveInterval=30 deploy@62.238.23.177" +export V1_CONTAINER=underlay-prod_postgres diff --git a/packages/migrate/scripts/staging-env.sh b/packages/migrate/scripts/staging-env.sh new file mode 100644 index 0000000..628a34f --- /dev/null +++ b/packages/migrate/scripts/staging-env.sh @@ -0,0 +1,24 @@ +# Environment for converting dev's v1 data into staging, or repairing it +# (edge-redesign-build.md, "Deployment targets"). Source it from the repo root: +# +# . packages/migrate/scripts/staging-env.sh +# +# Secrets are decrypted from .env.staging.enc and .env.dev.enc (SOPS) into this +# shell's environment only; nothing is written to disk. The v1 source is dev's +# Postgres, read over SSH (src/ssh-psql.ts), and dev's file bucket. + +_ul_dotenv() { sops -d --input-type dotenv --output-type dotenv "$1" | grep -E "^($2)="; } +eval "$(_ul_dotenv .env.staging.enc 'SIGNING_KEY|R2_ACCESS_KEY_ID|R2_SECRET_ACCESS_KEY' | sed 's/^/export /')" +eval "$(_ul_dotenv .env.dev.enc 'S3_ACCESS_KEY|S3_SECRET_KEY' | sed 's/^S3_/export V1_S3_/')" +unset -f _ul_dotenv + +_ul_r2=https://b66a542ad5e0af992703a2ce8d1d8747.r2.cloudflarestorage.com +# Target: staging's bucket. Source: v1's file bucket (shared by v1 dev and prod; read only). +export S3_ENDPOINT=$_ul_r2 S3_BUCKET=underlay-staging +export S3_ACCESS_KEY=$R2_ACCESS_KEY_ID S3_SECRET_KEY=$R2_SECRET_ACCESS_KEY +export V1_S3_ENDPOINT=$_ul_r2 V1_S3_BUCKET=underlay-assets +unset _ul_r2 +# dev's Postgres. The same box runs underlay-prod_postgres and underlay-mirror_postgres: +# ssh-psql refuses any container whose name doesn't start with "$V1_CONTAINER.". +export V1_SSH="-i $HOME/.ssh/kf_internal -o BatchMode=yes -o ServerAliveInterval=30 deploy@62.238.23.177" +export V1_CONTAINER=underlay-dev_postgres diff --git a/packages/migrate/scripts/v1-prod-to-prod.sh b/packages/migrate/scripts/v1-prod-to-prod.sh new file mode 100755 index 0000000..576aff1 --- /dev/null +++ b/packages/migrate/scripts/v1-prod-to-prod.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Convert production's v1 data for prod, www.underlay.org (edge-redesign-build.md, +# "Deployment targets"). +# +# packages/migrate/scripts/v1-prod-to-prod.sh [OUT] from the repo root +# COLLECTIONS=owner/slug,… packages/migrate/scripts/v1-prod-to-prod.sh +# SYNC=1 packages/migrate/scripts/v1-prod-to-prod.sh OUT bring an earlier OUT up to date +# (README, "v1 production → prod") +# +# Writes repository objects to the underlay-prod bucket, and migrated.sqlite, +# report.json and migrate.log to OUT (default: a new temp directory, outside +# Dropbox: the SQLite file holds production's users and sessions). Then load it: +# +# npx tsx packages/migrate/src/d1-data.ts $OUT/migrated.sqlite > $OUT/data.sql +# cd packages/server && npx wrangler d1 execute underlay-prod --env prod --remote --file $OUT/data.sql +# +# Into an empty D1 with migrations applied (d1-data.ts says why), with storage +# cleanup paused (README). It reads production's Postgres for the whole run, which +# took under two hours for dev. +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +OUT=${1:-$(mktemp -d -t ul-v1-prod-to-prod)} +mkdir -p "$OUT" +. packages/migrate/scripts/prod-env.sh +export TARGET_DB=file:$OUT/migrated.sqlite +echo "[v1-prod-to-prod] writing to $OUT" >&2 +NODE_OPTIONS=--max-old-space-size=8192 npx tsx packages/migrate/src/main.ts \ + > "$OUT/report.json" 2> >(tee "$OUT/migrate.log" >&2) diff --git a/packages/migrate/src/convert.ts b/packages/migrate/src/convert.ts new file mode 100644 index 0000000..d91412d --- /dev/null +++ b/packages/migrate/src/convert.ts @@ -0,0 +1,775 @@ +/** + * v1 (Postgres) → v2 conversion (edge-redesign.md, "Migration"). + * + * 1. Accounts and settings: better-auth tables, organizations, ARK tables and + * instance settings are copied row for row (same fields). v1's protocol + * comments (page_comments) are not: v2 dropped the feature. + * 2. Files: the `files` table is copied with v1 storage keys; `copyFiles` + * (files.ts) then moves the objects to their v2 keys. + * 3. Collections: each collection's ready versions are replayed oldest first + * through the v2 commit engine. Each version's changes are the diff between + * its v1 record set and the previous one, computed in Postgres and streamed + * in (type, id) order (COLLATE "C" = UTF-8 byte order, the trees' order), so + * the cost is O(changes) per version. Metadata-patch versions (shared record + * sets) produce no record changes. Versions keep their v1 semver and time. + * + * Sync (`sync: true`, SYNC=1): run again into the same target database to pick up + * what changed in v1 since. Account and settings rows are upserted, and the ones + * v1 no longer has are deleted (files and instance settings are only added to); a + * collection already converted resumes after its v2 head, matched to v1 by semver, + * and only newer versions are replayed. Collections v1 deleted are reported, not + * deleted. d1-data.ts --since then writes the difference for D1. + * + * Records, schemas and versions are re-hashed under v2, and their v1 hashes are + * not kept: nothing outside the platform refers to them. Field-level privacy is gone in v2: + * a type with private fields becomes a wholly private type, and the report says + * so (edge-redesign.md asks to check this is unused before migrating). + */ +import { createReadStream } from 'node:fs' +import { appendFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createInterface } from 'node:readline' + +import { + type Change, + hashRecord, + hashSchema, + newSalt, + OUT_OF_LINE_BYTES, + type RecordEntry, + utf8ByteLength, +} from '@underlay/protocol' +import { + type BaseVersion, + commitVersion, + dbSchema as schema, + type Ports, + publishCollectionInfo, + type TypeInput, +} from '@underlay/server' +import { and, desc, eq, getTableColumns, inArray, isNull, sql } from 'drizzle-orm' +import { getTableConfig, type SQLiteColumn } from 'drizzle-orm/sqlite-core' + +/** Anything that runs a parameterized query against the v1 database. */ +export interface V1Db { + query>(text: string, params?: unknown[]): Promise + /** + * A server-side cursor over an ordered query, read `batch` rows at a time; + * `next` returns [] at the end. Optional: without it, ordered reads page by key, + * which re-sorts on every page when the order has no matching index (v1's + * indexes use the database collation, the converter needs COLLATE "C"). + */ + cursor?>( + text: string, + params: unknown[], + batch: number, + ): Promise> +} + +export interface V1Cursor { + next(): Promise + close(): Promise +} + +export interface MigrationReport { + collections: number + versions: number + skippedVersions: { collection: string; semver: string; reason: string }[] + recordUpserts: number + fieldPrivateTypes: { collection: string; type: string }[] + /** + * v1 let a version hold one (type, id) twice with different data; v2 keys are + * unique. The newest record object is kept (then the lower hash); these were dropped. + */ + duplicateIds: { collection: string; semver: string; type: string; id: string; hash: string }[] + copied: Record + /** Sync: rows deleted because v1 no longer has them, by table. */ + deleted: Record + /** Sync: collections already converted that v1 no longer has (left in place). */ + removedInV1: string[] +} + +export const newReport = (): MigrationReport => ({ + collections: 0, + versions: 0, + skippedVersions: [], + recordUpserts: 0, + fieldPrivateTypes: [], + duplicateIds: [], + copied: {}, + deleted: {}, + removedInV1: [], +}) + +const camel = (s: string) => s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) + +/** + * How a sync treats a table's existing rows: `add` keeps them (a first run, and + * files and settings on a sync), `update` overwrites them with v1's, `mirror` also + * deletes the rows v1 no longer has. + */ +type CopyMode = 'add' | 'update' | 'mirror' + +/** A table's primary key columns, by property name. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function primaryKey(v2: any): { prop: string; column: SQLiteColumn }[] { + const cfg = getTableConfig(v2) + const cols: SQLiteColumn[] = cfg.columns.filter((c) => c.primary) + const pk = cols.length ? cols : (cfg.primaryKeys[0]?.columns ?? []) + const props = Object.entries(getTableColumns(v2) as Record) + return pk.map((column) => ({ prop: props.find(([, c]) => c === column)![0], column })) +} + +/** Copy a table whose v1 and v2 columns have the same names. Unknown columns are dropped. */ +// `any`: pnpm resolves drizzle-orm twice (different peers), so its table types don't unify. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function copyTable( + v1: V1Db, + ports: Ports, + v1Table: string, + v2: any, + report: MigrationReport, + map?: ((row: Record) => Record | null) | null, + mode: CopyMode = 'add', +) { + const columns = getTableColumns(v2) as Record + const pk = primaryKey(v2) + const keyOf = (r: Record) => JSON.stringify(pk.map((k) => String(r[k.prop]))) + const rows = await v1.query(`SELECT * FROM "${v1Table}"`) + const seen = new Set() + let n = 0 + for (let i = 0; i < rows.length; i += 50) { + const values = rows + .slice(i, i + 50) + .map((r) => { + const out: Record = {} + for (const [k, v] of Object.entries(r)) if (columns[camel(k)]) out[camel(k)] = v + return map ? map(out) : out + }) + .filter((r): r is Record => r !== null) + if (values.length === 0) continue + for (const v of values) seen.add(keyOf(v)) + const insert = ports.db.insert(v2).values(values as never) + if (mode === 'add') await insert.onConflictDoNothing() + else { + const set = Object.fromEntries( + Object.keys(values[0]!) + .filter((prop) => !pk.some((k) => k.prop === prop)) + .map((prop) => [prop, sql.raw(`excluded."${columns[prop]!.name}"`)]), + ) + await insert.onConflictDoUpdate({ target: pk.map((k) => k.column), set }) + } + n += values.length + } + report.copied[v1Table] = n + if (mode !== 'mirror') return + // `map` may drop rows that are out of scope; only rows it keeps are compared. + const existing = (await ports.db + .select(Object.fromEntries(pk.map((k) => [k.prop, k.column]))) + .from(v2)) as Record[] + const gone = existing.filter((r) => !seen.has(keyOf(r)) && (!map || map({ ...r }) !== null)) + for (const r of gone) { + await ports.db.delete(v2).where(and(...pk.map((k) => eq(k.column, r[k.prop])))) + } + if (gone.length) report.deleted[v1Table] = gone.length +} + +export async function migrateAccounts( + v1: V1Db, + ports: Ports, + report: MigrationReport, + sync = false, +): Promise { + const m: CopyMode = sync ? 'mirror' : 'add' + // Order follows foreign keys. + await copyTable(v1, ports, 'user', schema.user, report, null, m) + await copyTable(v1, ports, 'organization', schema.organization, report, null, m) + await copyTable(v1, ports, 'member', schema.member, report, null, m) + await copyTable(v1, ports, 'account', schema.account, report, null, m) + await copyTable(v1, ports, 'session', schema.session, report, null, m) + await copyTable(v1, ports, 'verification', schema.verification, report, null, m) + await copyTable(v1, ports, 'invitation', schema.invitation, report, null, m) + await copyTable(v1, ports, 'apikey', schema.apikey, report, null, m) + // v2 keeps settings of its own here (cleanup_paused), and file rows are + // repointed to v2 keys by copyFiles: both are only added to. + const settings: CopyMode = sync ? 'update' : 'add' + await copyTable(v1, ports, 'instance_settings', schema.instanceSettings, report, null, settings) + await copyTable(v1, ports, 'files', schema.files, report, (r) => ({ + ...r, + verifiedAt: r.createdAt, + })) +} + +/** Collection-scoped tables, after the collections exist. */ +/** + * A verified upload row per file the collection's versions held: the proof of + * possession a commit needs to reference a file it no longer holds (B2), so a + * migrated collection can re-add a record whose file it once had without + * uploading the bytes again. + */ +export async function recordPossession( + ports: Ports, + collectionId: string, + hashes: Iterable, +): Promise { + // A sync records only files the collection didn't already hold. + const held = new Set( + ( + await ports.db + .select({ hash: schema.fileUploads.hash }) + .from(schema.fileUploads) + .where( + and( + eq(schema.fileUploads.collectionId, collectionId), + eq(schema.fileUploads.status, 'verified'), + ), + ) + ).map((r) => r.hash), + ) + const all = [...hashes].filter((h) => !held.has(h)) + let n = 0 + for (let i = 0; i < all.length; i += 50) { + const rows = await ports.db + .select() + .from(schema.files) + .where(inArray(schema.files.hash, all.slice(i, i + 50))) + const values = rows.map((f) => ({ + collectionId, + hash: f.hash, + size: f.size, + mimeType: f.mimeType, + storageKey: ports.stores.canonicalFileKey(f.hash), + status: 'verified' as const, + })) + for (let j = 0; j < values.length; j += 10) { + await ports.db.insert(schema.fileUploads).values(values.slice(j, j + 10)) + } + n += values.length + } + return n +} + +export async function migrateCollectionSettings( + v1: V1Db, + ports: Ports, + report: MigrationReport, + /** Only these collections' rows (all when absent). */ + only?: Set, + sync = false, +): Promise { + const ours = (r: Record) => + !only || only.has(String(r.collectionId)) ? r : null + const m: CopyMode = sync ? 'mirror' : 'add' + await copyTable(v1, ports, 'collection_webhooks', schema.collectionWebhooks, report, ours, m) + // Shoulders aren't per collection: a partial sync only adds and updates them. + const shoulders: CopyMode = sync ? (only ? 'update' : 'mirror') : 'add' + await copyTable(v1, ports, 'ark_shoulders', schema.arkShoulders, report, null, shoulders) + await copyTable(v1, ports, 'ark_collections', schema.arkCollections, report, ours, m) + await copyTable(v1, ports, 'ark_record_types', schema.arkRecordTypes, report, ours, m) + // Labels move from v1 schema ids to v2 schema hashes. + const labels = await v1.query<{ label: string; schema: unknown; created_at: Date }>( + 'SELECT l.label, s.schema, l.created_at FROM schema_labels l JOIN schemas s ON s.id = l.schema_id', + ) + for (const l of labels) { + await ports.db + .insert(schema.schemaLabels) + .values({ schemaHash: hashSchema(l.schema), label: l.label, createdAt: l.created_at }) + .onConflictDoNothing() + } + report.copied.schema_labels = labels.length +} + +interface V1Version { + id: string + semver: string + hash: string + public_hash: string | null + base_semver: string | null + message: string | null + metadata: Record | null + pushed_by: string | null + app_id: string | null + actor_id: string | null + records_from_version_id: string | null + created_at: Date +} + +/** + * Protocol v2 refuses field-level privacy. A type that used it becomes a private + * type: its private fields are never exposed, at the cost of the public ones. + */ +function fixFieldPrivacy(s: Record): { + schema: Record + changed: boolean +} { + const props = s.properties as Record> | undefined + if (!props || !Object.values(props).some((p) => p && p.private === true)) + return { schema: s, changed: false } + const cleaned: Record = {} + for (const [k, p] of Object.entries(props)) { + const { private: _drop, ...rest } = p + void _drop + cleaned[k] = rest + } + return { schema: { ...s, properties: cleaned, private: true }, changed: true } +} + +const PAGE = 2000 +/** Rows per cursor fetch: one sort, then one round trip per batch. */ +const CURSOR_BATCH = 10_000 + +/** + * An ordered read by record id, a batch at a time: through a cursor where the + * reader has one, else keyset pages. `sql` takes `params`; `key` names the + * ordered id column, and `tiebreak` orders rows sharing an id. Paging appends + * `AND key > after` and a LIMIT, so it needs ids to be unique (it would skip a + * duplicate split across pages); cursors don't. + */ +async function idBatches( + v1: V1Db, + sql: (keyClause: string) => string, + params: unknown[], + key: string, + tiebreak = '', +): Promise> { + const order = ` ORDER BY ${key} COLLATE "C"${tiebreak}` + if (v1.cursor) return v1.cursor(sql('') + order, params, CURSOR_BATCH) + let after = '' + let done = false + const n = params.length + return { + next: async () => { + if (done) return [] + const rows = await v1.query( + sql(` AND ${key} COLLATE "C" > $${n + 1}`) + `${order} LIMIT $${n + 2}`, + [...params, after, PAGE], + ) + if (rows.length < PAGE) done = true + if (rows.length) after = rows[rows.length - 1]!.id + return rows + }, + close: async () => {}, + } +} + +type Routed = { public?: Change; private?: Change } + +export const migrateConfig = { + /** Held private changes past this many go to a temp file rather than memory. */ + holdInMemory: 50_000, +} + +/** + * Private changes read while the public side is being consumed, kept for the + * private side: in memory up to `migrateConfig.holdInMemory`, then in a temp file. + */ +class Held { + #mem: Change[] = [] + #file: string | null = null + + async push(c: Change) { + this.#mem.push(c) + if (this.#mem.length < migrateConfig.holdInMemory) return + this.#file ??= join(await mkdtemp(join(tmpdir(), 'ul-migrate-')), 'held.ndjson') + await appendFile(this.#file, this.#mem.map((x) => JSON.stringify(x)).join('\n') + '\n') + this.#mem = [] + } + + async *drain(): AsyncGenerator> { + if (this.#file) { + const lines = createInterface({ input: createReadStream(this.#file), crlfDelay: Infinity }) + for await (const l of lines) if (l) yield JSON.parse(l) as Change + await rm(dirname(this.#file), { recursive: true, force: true }) + this.#file = null + } + yield* this.#mem + this.#mem = [] + } +} + +/** + * A type's changes, read from v1 once and split into the public and private + * streams buildVersion takes. It merges the public side to the end before + * starting the private side, so private changes met on the way are held until + * then. With `withPublic` false (a private type), the private side reads alone. + */ +function splitBySet( + source: AsyncGenerator, + withPublic: boolean, +): { + public: AsyncGenerator> | null + private: AsyncGenerator> +} { + const held = new Held() + let publicDone = !withPublic + async function* pub() { + for await (const r of source) { + if (r.private) await held.push(r.private) + if (r.public) yield r.public + } + publicDone = true + } + async function* priv() { + if (!publicDone) throw new Error('splitBySet: the private side was read before the public side') + yield* held.drain() + if (!withPublic) for await (const r of source) if (r.private) yield r.private + } + return { public: withPublic ? pub() : null, private: priv() } +} + +/** One type's record changes between two v1 record sets, in id order. */ +async function* typeDelta( + v1: V1Db, + prev: string | null, + cur: string, + type: string, + onDuplicate: (id: string, hash: string) => void, +): AsyncGenerator<{ + id: string + upsert: { data: unknown; private: boolean; hash: string } | null +}> { + const upserts = await idBatches<{ id: string; private: boolean; h: string; data: unknown }>( + v1, + (keyClause) => + `SELECT vr.record_id AS id, vr.private, vr.record_hash AS h, ro.data + FROM version_records vr JOIN record_objects ro ON ro.hash = vr.record_hash + WHERE vr.version_id = $1 AND vr.type = $2${keyClause} + ${ + prev + ? `AND NOT EXISTS (SELECT 1 FROM version_records p WHERE p.version_id = $3 + AND p.type = vr.type AND p.record_id = vr.record_id AND p.record_hash = vr.record_hash + AND p.private = vr.private)` + : '' + }`, + prev ? [cur, type, prev] : [cur, type], + 'vr.record_id', + // Duplicate ids (v1 allowed them): the kept one first. + ', ro.created_at DESC, vr.record_hash', + ) + const deletes = prev + ? await idBatches<{ id: string }>( + v1, + (keyClause) => + `SELECT p.record_id AS id FROM version_records p + WHERE p.version_id = $1 AND p.type = $2${keyClause} + AND NOT EXISTS (SELECT 1 FROM version_records vr WHERE vr.version_id = $3 + AND vr.type = p.type AND vr.record_id = p.record_id)`, + [prev, type, cur], + 'p.record_id', + ) + : null + let ups: { id: string; private: boolean; h: string; data: unknown }[] = [] + let dels: { id: string }[] = [] + let doneU = false + let doneD = deletes === null + const fillU = async () => { + if (doneU || ups.length) return + ups = await upserts.next() + if (ups.length === 0) doneU = true + } + const fillD = async () => { + if (doneD || dels.length) return + dels = await deletes!.next() + if (dels.length === 0) doneD = true + } + // Byte order, to match COLLATE "C". + const lt = (a: string, b: string) => Buffer.compare(Buffer.from(a), Buffer.from(b)) < 0 + // A duplicated id (v1 allowed them) comes twice in a stream: use it once. + let lastUp: string | null = null + let lastDel: string | null = null + try { + for (;;) { + await fillU() + await fillD() + const u = ups[0] + const d = dels[0] + if (!u && !d) return + if (u && u.id === lastUp) { + ups.shift() + onDuplicate(u.id, u.h) + } else if (u && (!d || lt(u.id, d.id))) { + ups.shift() + lastUp = u.id + yield { id: u.id, upsert: { data: u.data, private: u.private, hash: u.h } } + } else if (d!.id === lastDel) { + dels.shift() + } else { + dels.shift() + lastDel = d!.id + yield { id: d!.id, upsert: null } + } + } + } finally { + await upserts.close() + await deletes?.close() + } +} + +export async function migrateCollection( + v1: V1Db, + ports: Ports, + collectionId: string, + report: MigrationReport, + sync = false, +): Promise { + const [col] = await v1.query<{ + id: string + organization_id: string + slug: string + name: string + public: boolean + created_at: Date + updated_at: Date + }>('SELECT * FROM collections WHERE id = $1', [collectionId]) + if (!col) throw new Error(`v1 collection ${collectionId} not found`) + const [existing] = sync + ? await ports.db.select().from(schema.collections).where(eq(schema.collections.id, col.id)) + : [] + if (existing) { + await ports.db + .update(schema.collections) + .set({ + organizationId: col.organization_id, + slug: col.slug, + name: col.name, + public: col.public, + }) + .where(eq(schema.collections.id, col.id)) + } else { + await ports.db.batch([ + ports.db.insert(schema.collections).values({ + id: col.id, + organizationId: col.organization_id, + slug: col.slug, + name: col.name, + public: col.public, + privateSalt: newSalt(), + createdAt: col.created_at, + updatedAt: col.updated_at, + }), + ports.db.insert(schema.placements).values({ + collectionId: col.id, + locationId: schema.PLATFORM_LOCATION_ID, + role: 'primary', + sets: 'public+private', + }), + ]) + } + report.collections++ + const repo = await ports.stores.forCollection(col.id) + + const versions = await v1.query( + `SELECT id::text, semver, hash, public_hash, base_semver, message, metadata, pushed_by, app_id, + actor_id, records_from_version_id::text, created_at + FROM versions WHERE collection_id = $1 AND status = 'ready' + ORDER BY created_at, major, minor, patch`, + [collectionId], + ) + + let base: BaseVersion | null = null + let prevRecords: string | null = null + let prevFiles = new Set() + const everFiles = new Set() + let start = 0 + + // A sync resumes after the v2 head: the v1 version with its semver. + const [head] = existing + ? await ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, col.id)) + .orderBy(desc(schema.versions.seq)) + .limit(1) + : [] + if (head) { + const i = versions.findIndex((v) => v.semver === head.semver) + if (i < 0) throw new Error(`${col.slug}: v2 head ${head.semver} has no ready v1 version`) + const hv = versions[i]! + base = { + id: head.id, + seq: head.seq, + semver: head.semver, + hash: head.hash, + publicRefsRoot: head.publicRefsRoot, + privateRefsRoot: head.privateRefsRoot, + } + prevRecords = hv.records_from_version_id ?? hv.id + prevFiles = new Set( + ( + await v1.query<{ file_hash: string }>( + 'SELECT file_hash FROM version_files WHERE version_id = $1', + [hv.id], + ) + ).map((f) => f.file_hash), + ) + start = i + 1 + } + + for (const v of versions.slice(start)) { + const recordsId = v.records_from_version_id ?? v.id + const schemaRows = await v1.query<{ slug: string; schema: Record }>( + 'SELECT vs.slug, s.schema FROM version_schemas vs JOIN schemas s ON s.id = vs.schema_id WHERE vs.version_id = $1', + [v.id], + ) + const root = base ? await repo.root(base.hash) : null + const basePriv = root?.private ? await repo.privateSet(root.private) : null + const fileRows = await v1.query<{ file_hash: string }>( + 'SELECT file_hash FROM version_files WHERE version_id = $1', + [v.id], + ) + const files = new Set(fileRows.map((f) => f.file_hash)) + for (const h of files) everFiles.add(h) + + const types: TypeInput[] = schemaRows.map((row) => { + const fixed = fixFieldPrivacy(row.schema) + if ( + fixed.changed && + !report.fieldPrivateTypes.some((t) => t.collection === col.id && t.type === row.slug) + ) { + report.fieldPrivateTypes.push({ collection: col.id, type: row.slug }) + } + const s = fixed.schema + const privateType = s.private === true + const hasPub = !!root?.public.types[row.slug]?.root + const hasPriv = !!basePriv?.types[row.slug]?.root + const unchanged = prevRecords === recordsId + const onDuplicate = (id: string, hash: string) => + report.duplicateIds.push({ + collection: col.slug, + semver: v.semver, + type: row.slug, + id, + hash, + }) + /** One v1 change, as a change in each set it touches. */ + const routed = async function* (): AsyncGenerator { + for await (const d of typeDelta(v1, prevRecords, recordsId, row.slug, onDuplicate)) { + if (!d.upsert) { + yield { + ...(hasPub && { public: { key: d.id, entry: null } }), + ...(hasPriv && { private: { key: d.id, entry: null } }), + } + continue + } + const target = privateType || d.upsert.private ? 'private' : 'public' + const { hash, canonical } = hashRecord(d.id, row.slug, d.upsert.data) + report.recordUpserts++ + const size = utf8ByteLength(canonical) + const body = + size > OUT_OF_LINE_BYTES ? await repo.putOutOfLine(hash, canonical) : canonical + const upsert = { key: d.id, entry: { key: d.id, hash, size, body } } + // Leaving the other set, if the type is there. + const gone = { key: d.id, entry: null } + yield target === 'public' + ? { public: upsert, ...(hasPriv && { private: gone }) } + : { private: upsert, ...(hasPub && { public: gone }) } + } + } + const split = unchanged ? null : splitBySet(routed(), !privateType) + return { + slug: row.slug, + schema: s, + schemaHash: hashSchema(s), + public: split?.public ?? null, + private: split?.private ?? null, + } + }) + + const r = await commitVersion(ports, { + collectionId: col.id, + base, + types, + metadata: v.metadata, + declaredFiles: { + add: [...files].filter((h) => !prevFiles.has(h)), + remove: [...prevFiles].filter((h) => !files.has(h)), + }, + message: v.message, + pushedBy: v.pushed_by, + appId: v.app_id, + actorId: v.actor_id, + migrated: { semver: v.semver, createdAt: v.created_at }, + }) + if (r.status === 'committed') { + report.versions++ + const nv = r.version + base = { + id: nv.id, + seq: nv.seq, + semver: nv.semver, + hash: nv.hash, + publicRefsRoot: nv.publicRefsRoot, + privateRefsRoot: nv.privateRefsRoot, + } + prevRecords = recordsId + prevFiles = files + } else { + // e.g. two v1 versions that differ only in what v2 no longer records. + report.skippedVersions.push({ collection: col.slug, semver: v.semver, reason: r.status }) + prevRecords = recordsId + prevFiles = files + } + } + + await recordPossession(ports, col.id, everFiles) + // Publishing the replayed versions stamped the conversion time; keep v1's. + await ports.db + .update(schema.collections) + .set({ updatedAt: col.updated_at }) + .where(eq(schema.collections.id, col.id)) +} + +/** Everything: accounts, collections (each with its history), then collection settings. */ +export async function migrateAll( + v1: V1Db, + ports: Ports, + opts: { + onCollection?: (slug: string) => void + /** Convert only these collections, named by id or `owner/slug` (accounts are always copied). */ + collections?: string[] + /** Bring a database this converter filled up to date with v1 (the header says how). */ + sync?: boolean + } = {}, +): Promise { + const report = newReport() + const sync = opts.sync ?? false + await migrateAccounts(v1, ports, report, sync) + const all = await v1.query<{ id: string; slug: string; owner: string }>( + `SELECT c.id::text, c.slug, o.slug AS owner FROM collections c + JOIN organization o ON o.id = c.organization_id ORDER BY c.created_at`, + ) + const wanted = opts.collections ? new Set(opts.collections) : null + const cols = wanted + ? all.filter((c) => wanted.has(c.id) || wanted.has(`${c.owner}/${c.slug}`)) + : all + if (wanted && cols.length !== wanted.size) { + const found = new Set(cols.flatMap((c) => [c.id, `${c.owner}/${c.slug}`])) + throw new Error(`No v1 collection: ${[...wanted].filter((w) => !found.has(w)).join(', ')}`) + } + for (const c of cols) { + opts.onCollection?.(c.slug) + await migrateCollection(v1, ports, c.id, report, sync) + } + await migrateCollectionSettings( + v1, + ports, + report, + wanted ? new Set(cols.map((c) => c.id)) : undefined, + sync, + ) + // collection.json last: a commit writes it before the collection's settings (its + // ARK) are copied, and a sync changes names and visibility without a version. + for (const c of cols) { + await publishCollectionInfo(ports, await ports.stores.forCollection(c.id), c.id) + } + if (sync && !wanted) { + const inV1 = new Set(all.map((c) => c.id)) + const ours = await ports.db + .select({ id: schema.collections.id, slug: schema.collections.slug }) + .from(schema.collections) + .where(isNull(schema.collections.deletedAt)) + report.removedInV1 = ours.filter((c) => !inV1.has(c.id)).map((c) => `${c.id} (${c.slug})`) + } + return report +} diff --git a/packages/migrate/src/d1-data.ts b/packages/migrate/src/d1-data.ts new file mode 100644 index 0000000..a4b1996 --- /dev/null +++ b/packages/migrate/src/d1-data.ts @@ -0,0 +1,147 @@ +/** + * Write a migrated SQLite database's rows as SQL for D1. + * + * npx tsx packages/migrate/src/d1-data.ts migrated.sqlite [table,…] > data.sql + * npx tsx packages/migrate/src/d1-data.ts migrated.sqlite --since loaded.sqlite > delta.sql + * wrangler d1 migrations apply --env --remote (the schema) + * wrangler d1 execute --env --remote --file data.sql + * + * Data only: the schema comes from the D1 migrations, so D1's own migration + * bookkeeping stays right and later migrations apply as usual. Rows are + * `INSERT OR IGNORE`, so a row a migration already seeded (the platform + * location) is kept rather than refused. Not OR REPLACE: replacing deletes the + * row first, and the delete cascades to rows already imported (placements). + * Tables are written parents first, by their foreign keys: D1's remote import + * doesn't keep `defer_foreign_keys` across the file, so a child row ahead of its + * parent failed the whole import (found loading dev into staging). The pragma + * stays for any cycle. Load into an empty database: existing rows win. + * + * `--since` writes only what changed from an earlier copy of the same file (the + * one last loaded into D1) to this one, after a sync run (SYNC=1, main.ts): new + * and changed rows as upserts on the primary key, parents first, then rows that + * are gone as deletes, children first. Rows D1 has that neither file has (made on + * the deployment itself) are left alone. + */ +import { type Client, createClient, type InValue } from '@libsql/client' + +const args = process.argv.slice(2) +const sinceAt = args.indexOf('--since') +const sinceFile = sinceAt >= 0 ? args.splice(sinceAt, 2)[1] : null +const file = args[0] +/** Only these tables (e.g. rows a repair added to a database already loaded). */ +const only = args[1] ? new Set(args[1].split(',')) : null +if (!file || (sinceAt >= 0 && !sinceFile)) { + console.error('Usage: d1-data.ts [table,…] [--since ]') + process.exit(2) +} + +const db = createClient({ url: `file:${file}` }) +const since = sinceFile ? createClient({ url: `file:${sinceFile}` }) : null +const SKIP = /^(sqlite_|_cf_|__drizzle_migrations$|d1_migrations$)/ + +function literal(v: InValue): string { + if (v === null || v === undefined) return 'NULL' + if (typeof v === 'number' || typeof v === 'bigint') return String(v) + if (typeof v === 'boolean') return v ? '1' : '0' + if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) { + const bytes = v instanceof ArrayBuffer ? new Uint8Array(v) : new Uint8Array(v.buffer) + return `X'${Buffer.from(bytes).toString('hex')}'` + } + return `'${String(v).replaceAll("'", "''")}'` +} + +const out = process.stdout +out.write('PRAGMA defer_foreign_keys = true;\n') +const tables = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") +const names = (tables.rows as unknown as { name: string }[]) + .map((r) => r.name) + .filter((n) => !SKIP.test(n) && (!only || only.has(n))) +const parents = new Map() +for (const n of names) { + const fks = await db.execute(`SELECT DISTINCT "table" AS t FROM pragma_foreign_key_list('${n}')`) + const ts = (fks.rows as unknown as { t: string }[]).map((r) => r.t) + parents.set( + n, + ts.filter((t) => t !== n && names.includes(t)), + ) +} +const ordered: string[] = [] +while (ordered.length < names.length) { + const ready = names.filter( + (n) => !ordered.includes(n) && parents.get(n)!.every((p) => ordered.includes(p)), + ) + if (ready.length === 0) { + const rest = names.filter((n) => !ordered.includes(n)) + console.error(`[d1-data] foreign key cycle among ${rest.join(', ')}; relying on deferral`) + ordered.push(...rest) + break + } + ordered.push(...ready) +} + +if (!since) { + for (const name of ordered) { + const rows = await db.execute(`SELECT * FROM "${name}"`) + if (rows.rows.length === 0) continue + const cols = rows.columns.map((c) => `"${c}"`).join(', ') + for (const row of rows.rows) { + const values = rows.columns.map((c) => literal(row[c] as InValue)).join(', ') + out.write(`INSERT OR IGNORE INTO "${name}" (${cols}) VALUES (${values});\n`) + } + console.error(`[d1-data] ${name}: ${rows.rows.length}`) + } +} else { + /** A table's rows as primary key → its values as SQL literals. */ + async function keyed(c: Client, name: string, pk: string[]) { + const exists = await c.execute({ + sql: "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + args: [name], + }) + const rows = new Map>() + if (exists.rows.length === 0) return rows + const res = await c.execute(`SELECT * FROM "${name}"`) + for (const row of res.rows) { + const values = new Map(res.columns.map((col) => [col, literal(row[col] as InValue)])) + const key = (pk.length ? pk : res.columns).map((col) => values.get(col)).join('\u0000') + rows.set(key, values) + } + return rows + } + const deletes: string[][] = [] + for (const name of ordered) { + const info = await db.execute(`SELECT name, pk FROM pragma_table_info('${name}')`) + const pk = (info.rows as unknown as { name: string; pk: number }[]) + .filter((r) => r.pk > 0) + .sort((a, b) => a.pk - b.pk) + .map((r) => r.name) + const [now, before] = [await keyed(db, name, pk), await keyed(since, name, pk)] + let upserts = 0 + for (const [key, values] of now) { + const old = before.get(key) + if (old && [...values].every(([col, v]) => old.get(col) === v)) continue + const cols = [...values.keys()] + const head = `INSERT INTO "${name}" (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${[...values.values()].join(', ')})` + const rest = cols.filter((c) => !pk.includes(c)) + // No primary key, or nothing but the key: the row is all there is to add. + out.write( + pk.length && rest.length + ? `${head} ON CONFLICT (${pk.map((c) => `"${c}"`).join(', ')}) DO UPDATE SET ${rest.map((c) => `"${c}" = excluded."${c}"`).join(', ')};\n` + : `${head.replace(/^INSERT/, 'INSERT OR IGNORE')};\n`, + ) + upserts++ + } + const gone: string[] = [] + for (const [key, values] of before) { + if (now.has(key)) continue + const where = (pk.length ? pk : [...values.keys()]) + .map((c) => (values.get(c) === 'NULL' ? `"${c}" IS NULL` : `"${c}" = ${values.get(c)}`)) + .join(' AND ') + gone.push(`DELETE FROM "${name}" WHERE ${where};`) + } + deletes.push(gone) + if (upserts || gone.length) + console.error(`[d1-data] ${name}: ${upserts} new or changed, ${gone.length} gone`) + } + // Children first, so a delete never waits on a child row that's going too. + for (const gone of deletes.reverse()) for (const line of gone) out.write(`${line}\n`) +} diff --git a/packages/migrate/src/files.ts b/packages/migrate/src/files.ts new file mode 100644 index 0000000..eab19c0 --- /dev/null +++ b/packages/migrate/src/files.ts @@ -0,0 +1,114 @@ +/** + * Copy v1 file objects into the deployment's bucket (edge-redesign.md decision + * 13, revised 2026-10-03; build doc finding 16). + * + * v1 keeps a file at `files/aa/bb/` in its own bucket, which dev and prod + * share. v2 keeps it at `/files/` in the deployment's bucket. After + * `migrateAll` has copied the `files` table (rows still naming v1 keys), this + * reads each such file from the v1 bucket, checks it hashes to its name and has + * its recorded size, writes it at the canonical key and points the row there. + * + * Re-runnable: rows already at their canonical key are skipped, and a file whose + * canonical object already exists with the right size is only repointed. Files + * that are missing, don't match, or are over `maxBytes` (copied in memory) keep + * their v1 key and are listed in the report. + */ +import { createHash } from 'node:crypto' + +import type { Store } from '@underlay/protocol' +import { dbSchema as schema, type Ports } from '@underlay/server' +import { eq } from 'drizzle-orm' + +export interface FileCopyReport { + copied: number + /** The canonical object already existed; only the row moved. */ + present: number + bytes: number + /** Hashes whose v1 object wasn't in the source bucket. */ + missing: string[] + /** Hashes whose v1 bytes didn't hash to the name or match the recorded size. */ + mismatched: string[] + /** Hashes over `maxBytes`, left at their v1 key. */ + tooLarge: string[] +} + +export const retryConfig = { attempts: 6, baseMs: 2000 } + +export async function copyFiles( + source: Store, + ports: Ports, + opts: { + concurrency?: number + maxBytes?: number + onFile?: (hash: string, done: number, total: number) => void + } = {}, +): Promise { + const { db, stores } = ports + const target = stores.fileBytes + const maxBytes = opts.maxBytes ?? 1024 ** 3 + const report: FileCopyReport = { + copied: 0, + present: 0, + bytes: 0, + missing: [], + mismatched: [], + tooLarge: [], + } + const rows = (await db.select().from(schema.files)).filter( + (f) => f.storageKey !== stores.canonicalFileKey(f.hash), + ) + + const repoint = (hash: string, storageKey: string) => + db.update(schema.files).set({ storageKey }).where(eq(schema.files.hash, hash)) + + /** A file that throws (a connect timeout to R2) is retried, backing off, before the copy fails. */ + const retrying = async (run: () => Promise) => { + for (let attempt = 1; ; attempt++) { + try { + return await run() + } catch (err) { + if (attempt >= retryConfig.attempts) throw err + console.error(`[files] retry ${attempt}: ${String(err)}`) + await new Promise((r) => setTimeout(r, retryConfig.baseMs * 2 ** (attempt - 1))) + } + } + } + + let next = 0 + let done = 0 + const worker = async () => { + while (next < rows.length) { + const f = rows[next++]! + await retrying(() => copyOne(f)) + opts.onFile?.(f.hash, ++done, rows.length) + } + } + const copyOne = async (f: (typeof rows)[number]) => { + const key = stores.canonicalFileKey(f.hash) + const head = await target.head(key) + if (head && head.size === f.size) { + await repoint(f.hash, key) + report.present++ + } else if (f.size > maxBytes) { + report.tooLarge.push(f.hash) + } else { + const obj = await source.get(f.storageKey) + if (!obj) { + report.missing.push(f.hash) + } else { + const bytes = await obj.bytes() + const hash = createHash('sha256').update(bytes).digest('hex') + if (hash !== f.hash || bytes.byteLength !== f.size) { + report.mismatched.push(f.hash) + } else { + await target.put(key, bytes, { contentType: f.mimeType, ifAbsent: true }) + await repoint(f.hash, key) + report.copied++ + report.bytes += f.size + } + } + } + } + await Promise.all(Array.from({ length: Math.max(1, opts.concurrency ?? 8) }, worker)) + return report +} diff --git a/packages/migrate/src/main.ts b/packages/migrate/src/main.ts new file mode 100644 index 0000000..20bad5b --- /dev/null +++ b/packages/migrate/src/main.ts @@ -0,0 +1,97 @@ +/** + * Run a v1 → v2 migration. + * + * V1_DATABASE_URL=postgres://… the v1 database (read only), or: + * V1_SSH="-i key -o BatchMode=yes user@host" and V1_CONTAINER=_postgres + * read it with docker exec … psql on that host + * (src/ssh-psql.ts); the container's name must + * start with "." + * TARGET_DB=file:./migrated.sqlite the v2 SQLite database to fill + * TARGET_DB_MIGRATE=0 TARGET_DB is a D1 export: don't run migrations + * S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY the v2 bucket (R2) + * REPO_PREFIX (repo), INTERNAL_PREFIX (internal) + * SIGNING_KEY the target deployment's log key + * V1_S3_ENDPOINT, V1_S3_BUCKET, V1_S3_ACCESS_KEY, V1_S3_SECRET_KEY + * the v1 bucket: when set, file objects are + * copied to their v2 keys (src/files.ts) + * FILES_ONLY=1 only copy files, into an existing TARGET_DB + * COLLECTIONS=owner/slug,… convert only these collections (ids work too); + * accounts and files are still copied whole + * SYNC=1 TARGET_DB is this converter's earlier output: bring + * it up to date with v1 (convert.ts header); then + * d1-data.ts --since writes what changed for D1 + * npx tsx packages/migrate/src/main.ts > report.json + * + * Then load the SQLite file into D1 (wrangler d1 export/import, or `.dump` and + * `wrangler d1 execute --file --remote`). + */ +import { s3Store } from '@underlay/protocol' +import { drainSqliteJobs } from '@underlay/server' +import postgres from 'postgres' + +import { migrateAll, type V1Db } from './convert.js' +import { copyFiles } from './files.js' +import { migrationPorts } from './ports.js' +import { sshPsql } from './ssh-psql.js' + +const env = process.env +const filesOnly = env.FILES_ONLY === '1' +if ((!env.V1_DATABASE_URL && !env.V1_SSH && !filesOnly) || !env.S3_ENDPOINT) { + console.error( + 'Set V1_DATABASE_URL and the S3_* target bucket variables (see the header of this file).', + ) + process.exit(2) +} + +const sql = postgres(env.V1_DATABASE_URL ?? '', { max: 2 }) +const ssh = + env.V1_SSH && !filesOnly + ? await sshPsql({ + ssh: env.V1_SSH.split(/\s+/).map((a) => a.replace(/^~(?=\/)/, env.HOME ?? '~')), + container: env.V1_CONTAINER ?? '', + requirePrefix: `${env.V1_CONTAINER}.`, + }) + : null +const v1: V1Db = ssh ?? { + query: async (text, params) => (await sql.unsafe(text, (params ?? []) as never[])) as never, +} + +const { ports } = await migrationPorts(env) + +const started = Date.now() +let report: object = {} +if (!filesOnly) { + report = await migrateAll(v1, ports, { + onCollection: (slug) => console.error(`[migrate] ${slug}`), + ...(env.COLLECTIONS && { collections: env.COLLECTIONS.split(',').map((c) => c.trim()) }), + sync: env.SYNC === '1', + }) + // Reference-log indexing and compaction run as jobs; finish them here. + await drainSqliteJobs(ports) +} +if (env.V1_S3_BUCKET) { + const source = s3Store({ + endpoint: env.V1_S3_ENDPOINT ?? env.S3_ENDPOINT, + bucket: env.V1_S3_BUCKET, + accessKeyId: env.V1_S3_ACCESS_KEY ?? '', + secretAccessKey: env.V1_S3_SECRET_KEY ?? '', + region: 'auto', + }) + try { + const files = await copyFiles(source, ports, { + onFile: (_, done, total) => { + if (done % 100 === 0 || done === total) console.error(`[files] ${done}/${total}`) + }, + }) + report = { ...report, files } + } catch (err) { + // Keep the conversion's report: the copy is re-runnable (FILES_ONLY=1), the conversion isn't. + console.log(JSON.stringify({ ...report, filesError: String(err) }, null, 2)) + throw err + } +} +console.log( + JSON.stringify({ ...report, seconds: Math.round((Date.now() - started) / 1000) }, null, 2), +) +ssh?.close() +await sql.end() diff --git a/packages/migrate/src/ports.ts b/packages/migrate/src/ports.ts new file mode 100644 index 0000000..3d7eea5 --- /dev/null +++ b/packages/migrate/src/ports.ts @@ -0,0 +1,38 @@ +/** + * The ports a migration tool writes through: a local SQLite file standing in + * for the deployment's D1, and the deployment's bucket. Shared by main.ts and + * repair.ts; both read the same environment (see main.ts). + */ +import { ed25519Signer, generateSigningKey, s3Store, type Signer } from '@underlay/protocol' +import { createStores, MemoryCache, openNodeDb, type Ports, SqliteJobs } from '@underlay/server' + +export async function migrationPorts( + env: NodeJS.ProcessEnv, +): Promise<{ ports: Ports; signer: Signer }> { + // TARGET_DB_MIGRATE=0: a copy of a deployment's D1 (wrangler d1 export), already at its schema. + const db = await openNodeDb(env.TARGET_DB ?? 'file:./migrated.sqlite', { + migrate: env.TARGET_DB_MIGRATE !== '0', + }) + const cache = new MemoryCache() + const signer = await ed25519Signer(env.SIGNING_KEY ?? (await generateSigningKey())) + const ports: Ports = { + db, + cache, + stores: createStores(db, cache, { + bucket: s3Store({ + endpoint: env.S3_ENDPOINT ?? '', + bucket: env.S3_BUCKET ?? 'underlay', + accessKeyId: env.S3_ACCESS_KEY ?? '', + secretAccessKey: env.S3_SECRET_KEY ?? '', + region: env.S3_REGION ?? 'auto', + }), + repoPrefix: env.REPO_PREFIX ?? 'repo', + internalPrefix: env.INTERNAL_PREFIX ?? 'internal', + }), + jobs: new SqliteJobs(db), + signer: async () => signer, + outboundFetch: () => Promise.reject(new Error('No outbound requests during migration')), + waitUntil: (p) => void p.catch((err) => console.error(err)), + } + return { ports, signer } +} diff --git a/packages/migrate/src/repair.ts b/packages/migrate/src/repair.ts new file mode 100644 index 0000000..bd30ca8 --- /dev/null +++ b/packages/migrate/src/repair.ts @@ -0,0 +1,143 @@ +/** + * Bring a converted deployment up to the current format without converting it + * again (edge-redesign-build.md, "Deployment targets"). Written for staging after + * the alignment-review fixes, when the data was right but the logs and file + * possession were not. + * + * TARGET_DB=file:./migrated.sqlite the converted database, as loaded into D1 + * S3_*, REPO_PREFIX, SIGNING_KEY the deployment's bucket and log key (main.ts) + * COLLECTIONS=owner/slug,… only these collections (default: all) + * STEPS=logs,possession,refs which steps (default: logs,possession) + * REFS_SQL=./refs.sql where the refs step writes its D1 updates + * npx tsx packages/migrate/src/repair.ts + * + * Per collection: + * 1. **Logs.** Deletes `collections//` (collection.json, log entries, head.json) + * and writes the log again from the versions table with appendVersionLog, so + * entries take the current format (B8: they sign the collection id). The + * versions themselves are untouched; only the signed record of them changes. + * 2. **Possession.** A verified file_uploads row for every file any version held + * (both sets' file trees), as the converter now writes (B2). Rows go into + * TARGET_DB; load them with `d1-data.ts file_uploads`. + * 3. **Refs** (2026-10-04). Rebuilds the head version's file reference count + * trees with per-type counts, so moving or removing a type stops reading its + * records (packages/protocol/src/repo/file-sets.ts). Only heads whose trees + * lack them; a null tree needs nothing. Writes `UPDATE versions …` lines to + * REFS_SQL for `wrangler d1 execute --file` (d1-data.ts only inserts). + * Then every rewritten log is verified against the signing key. + */ +import { appendFileSync, writeFileSync } from 'node:fs' + +import { + emptySet, + fileTree, + iterate, + listAll, + rebuildFileRefs, + RepoSource, + tracksTypes, + verifyLog, +} from '@underlay/protocol' +import { appendVersionLog, dbSchema as schema } from '@underlay/server' +import { asc, eq } from 'drizzle-orm' + +import { recordPossession } from './convert.js' +import { migrationPorts } from './ports.js' + +const env = process.env +const { ports, signer } = await migrationPorts(env) +const { db } = ports + +const steps = new Set((env.STEPS ?? 'logs,possession').split(',').map((s) => s.trim())) +const refsSql = env.REFS_SQL ?? './refs.sql' +if (steps.has('refs')) writeFileSync(refsSql, '') +const sqlText = (v: string | null) => (v === null ? 'NULL' : `'${v.replace(/'/g, "''")}'`) + +const wanted = env.COLLECTIONS ? new Set(env.COLLECTIONS.split(',').map((c) => c.trim())) : null +const cols = ( + await db + .select({ + id: schema.collections.id, + slug: schema.collections.slug, + headVersionId: schema.collections.headVersionId, + owner: schema.organization.slug, + }) + .from(schema.collections) + .innerJoin(schema.organization, eq(schema.organization.id, schema.collections.organizationId)) +).filter((c) => !wanted || wanted.has(c.id) || wanted.has(`${c.owner}/${c.slug}`)) + +const report: { + collection: string + versions: number + possession?: number + refs?: 'rebuilt' | 'current' +}[] = [] +for (const c of cols) { + const repo = await ports.stores.forCollection(c.id) + const versions = await db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, c.id)) + .orderBy(asc(schema.versions.seq)) + + const line: (typeof report)[number] = { + collection: `${c.owner}/${c.slug}`, + versions: versions.length, + } + + // 1. Logs, from scratch. + if (steps.has('logs')) { + const old: string[] = [] + for await (const k of listAll(repo.blobs, `collections/${c.id}/`)) old.push(k) + for (const k of old) await repo.blobs.delete(k) + for (const v of versions) await appendVersionLog(ports, repo, c.id, v) + // A collection with no versions has no log. + const { entries } = + versions.length > 0 ? await verifyLog(repo, c.id, [signer.publicKey]) : { entries: [] } + if (entries.length !== versions.length) { + throw new Error( + `${c.owner}/${c.slug}: ${entries.length} log entries for ${versions.length} versions`, + ) + } + } + + // 2. Possession of every file a version held. + if (steps.has('possession')) { + const held = new Set() + const files = new RepoSource(fileTree, repo) + for (const v of versions) { + const root = await repo.root(v.hash) + const sets = [root.public, ...(root.private ? [await repo.privateSet(root.private)] : [])] + for (const set of sets) for await (const f of iterate(files, set.files.root)) held.add(f.key) + } + await db.delete(schema.fileUploads).where(eq(schema.fileUploads.collectionId, c.id)) + line.possession = await recordPossession(ports, c.id, held) + } + + // 3. Per-type counts in the head's reference count trees. + const head = versions.find((v) => v.id === c.headVersionId) + if (steps.has('refs') && head) { + const current = + (await tracksTypes(repo, head.publicRefsRoot)) && + (await tracksTypes(repo, head.privateRefsRoot)) + if (current) line.refs = 'current' + else { + const root = await repo.root(head.hash) + const priv = root.private ? await repo.privateSet(root.private) : emptySet() + const refs = await rebuildFileRefs(repo, { public: root.public, private: priv }) + appendFileSync( + refsSql, + `UPDATE versions SET public_refs_root = ${sqlText(refs.public)}, private_refs_root = ${sqlText(refs.private)} WHERE id = ${sqlText(head.id)};\n`, + ) + await db + .update(schema.versions) + .set({ publicRefsRoot: refs.public, privateRefsRoot: refs.private }) + .where(eq(schema.versions.id, head.id)) + line.refs = 'rebuilt' + } + } + + report.push(line) + console.error(`[repair] ${JSON.stringify(line)}`) +} +console.log(JSON.stringify(report, null, 2)) diff --git a/packages/migrate/src/ssh-psql.ts b/packages/migrate/src/ssh-psql.ts new file mode 100644 index 0000000..bf9a910 --- /dev/null +++ b/packages/migrate/src/ssh-psql.ts @@ -0,0 +1,187 @@ +/** + * A v1 database read through `docker exec … psql` on a remote host, for a v1 + * database with no reachable port and too big to copy (dev is 18 GB). + * + * One long-lived `ssh host docker exec -i psql` session; queries run + * one at a time. Each query is wrapped as `SELECT row_to_json(q)::text FROM (…) q` + * so every row comes back as one line of JSON, followed by a sentinel line that + * carries psql's SQLSTATE. Columns come back as postgres.js would give them to + * the converter: json/jsonb as values, booleans, numbers, and timestamp and date + * columns as Date (found per query shape with `\gdesc`). Ordered reads go through + * server-side cursors in a read-only transaction, so a big type is sorted once + * rather than once per page. + * + * The container is found by name on the host and must be exactly one match. + * POSTGRES_USER and POSTGRES_DB are expanded inside the container, so no + * credentials leave the box. Read-only: the session is opened with + * `default_transaction_read_only`. + * + * Parameters are inlined as literals (strings and finite numbers only, which is + * all the converter binds); `$n` must not appear in the query text otherwise. + */ +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process' + +import type { V1Db } from './convert.js' + +export interface SshPsqlOptions { + /** ssh arguments before the remote command, e.g. ['-i', key, '-o', 'BatchMode=yes', 'deploy@host']. */ + ssh: string[] + /** A `docker ps --filter name=` value that matches exactly one container. */ + container: string + /** Refuse unless the matched container's name starts with this (a guard against prod). */ + requirePrefix?: string + /** Replaces ssh for tests: run psql locally with these arguments prepended. */ + command?: string[] +} + +const DATE_TYPES = /^(timestamp|date)/ + +function literal(v: unknown): string { + if (v === null || v === undefined) return 'NULL' + if (typeof v === 'number' && Number.isFinite(v)) return String(v) + if (typeof v === 'string') return `'${v.replaceAll("'", "''")}'` + throw new Error(`sshPsql: unsupported parameter ${typeof v}`) +} + +export function inlineParams(text: string, params: unknown[] = []): string { + return text.replace(/\$(\d+)\b/g, (_, n: string) => { + const i = Number(n) - 1 + if (i >= params.length) throw new Error(`sshPsql: no parameter $${n}`) + return literal(params[i]) + }) +} + +export async function sshPsql(opts: SshPsqlOptions): Promise { + const filter = opts.container.replaceAll("'", '') + const psql = `psql -X -q -At -v ON_ERROR_STOP=0 -U "$POSTGRES_USER" -d "$POSTGRES_DB"` + const remote = + `C=$(docker ps -q -f name='${filter}'); ` + + `N=$(docker ps --format '{{.Names}}' -f name='${filter}'); ` + + `[ "$(echo "$C" | grep -c .)" = 1 ] || { echo "matched: $N" >&2; exit 3; }; ` + + (opts.requirePrefix + ? `case "$N" in '${opts.requirePrefix.replaceAll("'", '')}'*) ;; *) echo "refusing $N" >&2; exit 4;; esac; ` + : '') + + `exec docker exec -i -e PGOPTIONS='-c default_transaction_read_only=on' "$C" sh -c '${psql}'` + const proc: ChildProcessWithoutNullStreams = opts.command + ? spawn(opts.command[0]!, [...opts.command.slice(1)], { stdio: 'pipe' }) + : spawn('ssh', [...opts.ssh, remote], { stdio: 'pipe' }) + + let out = '' + let err = '' + let waiting: (() => void) | null = null + let exited: string | null = null + proc.stdout.setEncoding('utf8') + proc.stderr.setEncoding('utf8') + proc.stdout.on('data', (d: string) => { + out += d + waiting?.() + }) + proc.stderr.on('data', (d: string) => { + err += d + }) + proc.on('exit', (code) => { + exited = `psql session ended (${code}): ${err.trim()}` + waiting?.() + }) + + let seq = 0 + /** Send a script and collect stdout lines up to its sentinel. */ + async function run(script: string): Promise { + const tag = `__ul_end_${++seq}__` + err = '' + // The error text rides on the sentinel: psql's stderr can arrive after stdout. + proc.stdin.write(`${script}\n\\echo ${tag} :SQLSTATE :LAST_ERROR_MESSAGE\n`) + const marker = `${tag} ` + for (;;) { + const at = out.indexOf(marker) + if (at !== -1) { + const eol = out.indexOf('\n', at) + if (eol !== -1) { + const [state, ...message] = out + .slice(at + marker.length, eol) + .trim() + .split(' ') + const body = out.slice(0, at) + out = out.slice(eol + 1) + if (state !== '00000') { + throw new Error(`v1 query failed (${state}): ${message.join(' ') || err.trim()}`) + } + return body.split('\n').filter((l) => l !== '') + } + } + if (exited) throw new Error(exited) + await new Promise((r) => (waiting = r)) + waiting = null + } + } + + let queue: Promise = Promise.resolve() + const serial = (f: () => Promise): Promise => { + const p = queue.then(f, f) + queue = p.catch(() => {}) + return p + } + + const dateColumns = new Map() + async function dates(template: string, sql: string): Promise { + let cols = dateColumns.get(template) + if (!cols) { + const desc = await run(`${sql}\n\\gdesc`) + cols = desc + .map((l) => l.split('|')) + .filter(([, type]) => DATE_TYPES.test(type ?? '')) + .map(([name]) => name!) + dateColumns.set(template, cols) + } + return cols + } + + await serial(() => run('SELECT 1;')) + + const parse = (lines: string[], cols: string[]) => + lines.map((l) => { + const row = JSON.parse(l) as Record + for (const c of cols) if (typeof row[c] === 'string') row[c] = new Date(row[c] as string) + return row as T + }) + const prepare = (text: string, params?: unknown[]) => + inlineParams(text, params).trim().replace(/;$/, '') + + // Cursors live in one read-only transaction, open while any cursor is. + let openCursors = 0 + let cursorSeq = 0 + + return { + query: (text: string, params?: unknown[]) => + serial(async () => { + const sql = prepare(text, params) + const cols = await dates(text, sql) + return parse(await run(`SELECT row_to_json(q)::text FROM (${sql}) q;`), cols) + }), + cursor: (text: string, params: unknown[], batch: number) => + serial(async () => { + const sql = prepare(text, params) + const cols = await dates(text, sql) + const name = `ul_c${++cursorSeq}` + if (openCursors === 0) await run('BEGIN READ ONLY;') + openCursors++ + await run( + `DECLARE ${name} NO SCROLL CURSOR FOR SELECT row_to_json(q)::text FROM (${sql}) q;`, + ) + let closed = false + return { + next: () => serial(async () => parse(await run(`FETCH ${batch} FROM ${name};`), cols)), + close: () => + serial(async () => { + if (closed) return + closed = true + await run(`CLOSE ${name};`) + if (--openCursors === 0) await run('COMMIT;') + }), + } + }), + close: () => { + proc.stdin.end() + }, + } +} diff --git a/packages/migrate/test/convert.test.ts b/packages/migrate/test/convert.test.ts new file mode 100644 index 0000000..ad58b8e --- /dev/null +++ b/packages/migrate/test/convert.test.ts @@ -0,0 +1,336 @@ +/** + * The converter against a real v1 schema: PGlite (Postgres in WASM) runs v1's + * own migrations, a fixture collection is written the way v1 stores it, and the + * result is checked through the v2 API. + */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { PGlite } from '@electric-sql/pglite' +import { hashRecord, hashSchema, sha256Hex, verifyLog } from '@underlay/protocol' +import { dbSchema } from '@underlay/server' +import { afterAll, describe, expect, it } from 'vitest' + +import { cleanup, harness } from '../../server/test/harness.js' +import { migrateAll, migrateConfig, type V1Db } from '../src/convert.js' + +afterAll(cleanup) + +/** v1's own migrations (from main at 7f6e1c6), the schema the converter reads. */ +const v1Schema = join(import.meta.dirname, 'v1-schema') + +/** v1 in PGlite; with `cursors`, ordered reads use server-side cursors fetched a row at a time. */ +async function v1Database( + cursors = false, +): Promise<{ pg: PGlite; db: V1Db & { recordReads: number } }> { + const pg = new PGlite() + const journal = JSON.parse(await readFile(join(v1Schema, 'meta/_journal.json'), 'utf8')) as { + entries: { tag: string }[] + } + for (const { tag } of journal.entries) { + const sql = await readFile(join(v1Schema, `${tag}.sql`), 'utf8') + for (const stmt of sql.split('--> statement-breakpoint')) if (stmt.trim()) await pg.exec(stmt) + } + let open = 0 + let seq = 0 + const db: V1Db & { recordReads: number } = { + recordReads: 0, + query: async (text, params) => (await pg.query(text, params ?? [])).rows as never, + ...(cursors && { + cursor: async (text: string, params: unknown[]) => { + if (text.includes('JOIN record_objects')) db.recordReads++ + if (open++ === 0) await pg.query('BEGIN') + const name = `c${++seq}` + await pg.query(`DECLARE ${name} NO SCROLL CURSOR FOR ${text}`, params) + return { + next: async () => (await pg.query(`FETCH 1 FROM ${name}`)).rows as never, + close: async () => { + await pg.query(`CLOSE ${name}`) + if (--open === 0) await pg.query('COMMIT') + }, + } + }, + }), + } + return { pg, db } +} + +const Author = { + type: 'object', + properties: { name: { type: 'string' }, scores: { type: 'object' }, photo: {} }, +} +const FILE = 'f'.repeat(64) + +describe('v1 → v2 migration', () => { + it.each([ + ['keyset pages', false], + ['cursors', true], + ])( + 'replays a collection history with its privacy and files, re-hashed (%s)', + async (_, cursors) => { + const { pg, db } = await v1Database(cursors) + const q = (s: string, p: unknown[] = []) => pg.query(s, p) + await q(`INSERT INTO "user" (id, name, email) VALUES ('u1', 'Ada', 'ada@example.org')`) + await q(`INSERT INTO organization (id, name, slug) VALUES ('o1', 'Org', 'org')`) + await q( + `INSERT INTO member (id, organization_id, user_id, role) VALUES ('m1', 'o1', 'u1', 'owner')`, + ) + await q( + `INSERT INTO collections (id, organization_id, slug, name, public) VALUES ('11111111-1111-1111-1111-111111111111', 'o1', 'lib', 'Lib', true)`, + ) + await q( + `INSERT INTO files (hash, size, mime_type, storage_key) VALUES ($1, 3, 'image/png', 'files/ff/ff/legacy')`, + [FILE], + ) + const s = await q(`INSERT INTO schemas (schema, schema_hash) VALUES ($1, $2) RETURNING id`, [ + Author, + hashSchema(Author), + ]) + const schemaId = (s.rows[0] as { id: string }).id + + const records = { + a1: { id: 'a', data: { name: 'A', photo: { $file: `sha256:${FILE}` } } }, + a2: { id: 'a', data: { name: 'A2', photo: { $file: `sha256:${FILE}` } } }, + b: { id: 'b', data: { name: 'B', scores: { 10: 1, 9: 2 } } }, // integer-like keys: re-hashed + c: { id: 'c', data: { name: 'C' } }, + // v1 let a version hold an id twice; the newer record object wins. + cOld: { id: 'c', data: { name: 'C, older' } }, + } + // v1's own record hashes: the converter re-hashes, so any unique value will do. + const v1hash = (r: { id: string; data: unknown }) => + sha256Hex(JSON.stringify({ v1: [r.id, r.data] })) + for (const r of Object.values(records)) { + await q( + `INSERT INTO record_objects (hash, record_id, type, data, size, created_at) VALUES ($1, $2, 'Author', $3, 10, $4) ON CONFLICT DO NOTHING`, + [ + v1hash(r), + r.id, + r.data, + r === records.cOld ? '2020-01-01T00:00:00Z' : '2025-01-01T00:00:00Z', + ], + ) + } + const version = async ( + semver: string, + members: [{ id: string; data: unknown }, boolean][], + extra: { recordsFrom?: number; metadata?: object; at: string }, + ) => { + const [maj, min, pat] = semver.slice(1).split('.').map(Number) + const v = await q( + `INSERT INTO versions (collection_id, semver, major, minor, patch, hash, public_hash, metadata, record_count, file_count, total_bytes, records_from_version_id, status, created_at) + VALUES ('11111111-1111-1111-1111-111111111111', $1, $2, $3, $4, $5, $6, $7, $8, 1, 0, $9, 'ready', $10) RETURNING id`, + [ + semver, + maj, + min, + pat, + `private:${semver}`, + `public:${semver}`, + extra.metadata ?? null, + members.length, + extra.recordsFrom ?? null, + extra.at, + ], + ) + const id = (v.rows[0] as { id: number }).id + await q( + `INSERT INTO version_schemas (version_id, slug, schema_id) VALUES ($1, 'Author', $2)`, + [id, schemaId], + ) + await q(`INSERT INTO version_files (version_id, file_hash) VALUES ($1, $2)`, [id, FILE]) + for (const [r, priv] of members) { + await q( + `INSERT INTO version_records (version_id, record_hash, record_id, type, private) VALUES ($1, $2, $3, 'Author', $4)`, + [id, v1hash(r), r.id, priv], + ) + } + return id + } + await version( + 'v1.0.0', + [ + [records.a1, false], + [records.b, false], + [records.c, true], + [records.cOld, true], + ], + { metadata: { title: 'Lib' }, at: '2026-01-01T00:00:00Z' }, + ) + const v11 = await version( + 'v1.1.0', + [ + [records.a2, false], + [records.b, true], + ], + { metadata: { title: 'Lib' }, at: '2026-02-01T00:00:00Z' }, + ) + await version('v1.1.1', [], { + recordsFrom: v11, + metadata: { title: 'Lib', readme: 'hi' }, + at: '2026-03-01T00:00:00Z', + }) + + const h = await harness() + // With cursors, also hold private changes on disk from the first one. + migrateConfig.holdInMemory = cursors ? 1 : 50_000 + const report = await migrateAll(db, h.ports) + migrateConfig.holdInMemory = 50_000 + // Each type is read once per version with changes, not once per set: + // v1.0.0 and v1.1.0 (v1.1.1 shares v1.1.0's records). + if (cursors) expect(db.recordReads).toBe(2) + await h.drain() + expect(report).toMatchObject({ + collections: 1, + versions: 3, + skippedVersions: [], + fieldPrivateTypes: [], + duplicateIds: [ + { + collection: 'lib', + semver: 'v1.0.0', + type: 'Author', + id: 'c', + hash: v1hash(records.cOld), + }, + ], + }) + + const json = async (path: string, user?: string) => + (await (await h.request(path, user ? { user } : {})).json()) as any + // Proof of possession for every file the collection's versions held. + expect(await h.ports.db.select().from(dbSchema.fileUploads)).toMatchObject([ + { collectionId: '11111111-1111-1111-1111-111111111111', hash: FILE, status: 'verified' }, + ]) + // A run limited to named collections refuses a name it can't find. + await expect( + migrateAll(db, (await harness()).ports, { collections: ['org/nope'] }), + ).rejects.toThrow('No v1 collection: org/nope') + const [lib] = await h.ports.db.select().from(dbSchema.collections) + const v1Updated = (await pg.query(`SELECT updated_at FROM collections`)).rows[0] as { + updated_at: Date + } + expect(lib!.updatedAt.getTime()).toBe(v1Updated.updated_at.getTime()) + const versions = await json('/api/collections/org/lib/versions', 'u1') + const first = await json('/api/collections/org/lib/versions/v1.0.0/records?type=Author', 'u1') + expect(first.records.find((r: any) => r.id === 'c').data).toEqual({ name: 'C' }) + expect(versions.map((v: any) => v.semver)).toEqual(['v1.1.1', 'v1.1.0', 'v1.0.0']) + expect(versions.map((v: any) => v.recordCount)).toEqual([2, 2, 3]) + // Anonymous readers see only the public set: b went private in v1.1.0. + const pub = await json('/api/collections/org/lib/versions/v1.1.1/records') + expect(pub.records.map((r: any) => r.id)).toEqual(['a']) + const latest = await json('/api/collections/org/lib/versions/latest', 'u1') + expect(latest.metadata).toEqual({ title: 'Lib', readme: 'hi' }) + // The file kept its v1 storage key and is downloadable. + const file = await h.request(`/api/collections/org/lib/files/${FILE}`) + expect(file.status).toBe(302) + expect(file.headers.get('location')).toContain('files/ff/ff/legacy') + // Provenance by b's v2 hash (members only now); its v1 hash is gone. + const bHash = hashRecord('b', 'Author', records.b.data).hash + const prov = await json(`/api/records/${bHash}/provenance`, 'u1') + expect(prov.recordHash).toBe(bHash) + expect( + (await h.request(`/api/records/${v1hash(records.b)}/provenance`, { user: 'u1' })).status, + ).toBe(404) + expect(prov.references.map((r: any) => r.semver)).toEqual(['v1.0.0', 'v1.1.0', 'v1.1.1']) + // The signed log over the replayed history; v1 version hashes don't resolve. + const repo = await h.ports.stores.forCollection('11111111-1111-1111-1111-111111111111') + const { entries } = await verifyLog(repo, '11111111-1111-1111-1111-111111111111', [ + h.signer.publicKey, + ]) + expect(entries.map((e) => [e.semver, e.createdAt.slice(0, 10)])).toEqual([ + ['v1.0.0', '2026-01-01'], + ['v1.1.0', '2026-02-01'], + ['v1.1.1', '2026-03-01'], + ]) + expect( + (await h.request('/api/collections/org/lib/versions/private:v1.1.0', { user: 'u1' })) + .status, + ).toBe(404) + }, + ) +}) + +describe('v1 → v2 sync', () => { + it('replays only newer versions and mirrors account rows', async () => { + const { pg, db } = await v1Database(true) + const q = (s: string, p: unknown[] = []) => pg.query(s, p) + const COL = '22222222-2222-2222-2222-222222222222' + await q(`INSERT INTO "user" (id, name, email) VALUES ('u1', 'Ada', 'ada@example.org')`) + await q(`INSERT INTO "user" (id, name, email) VALUES ('u2', 'Bo', 'bo@example.org')`) + await q(`INSERT INTO organization (id, name, slug) VALUES ('o1', 'Org', 'org')`) + await q( + `INSERT INTO member (id, organization_id, user_id, role) VALUES ('m1', 'o1', 'u1', 'owner')`, + ) + await q( + `INSERT INTO collections (id, organization_id, slug, name, public) VALUES ($1, 'o1', 'lib', 'Lib', true)`, + [COL], + ) + const s = await q(`INSERT INTO schemas (schema, schema_hash) VALUES ($1, $2) RETURNING id`, [ + Author, + hashSchema(Author), + ]) + const schemaId = (s.rows[0] as { id: string }).id + const record = async (id: string, name: string) => { + const hash = sha256Hex(`v1:${id}:${name}`) + await q( + `INSERT INTO record_objects (hash, record_id, type, data, size) VALUES ($1, $2, 'Author', $3, 10)`, + [hash, id, { name }], + ) + return { id, hash } + } + const version = async (semver: string, members: { id: string; hash: string }[], at: string) => { + const [maj, min, pat] = semver.slice(1).split('.').map(Number) + const v = await q( + `INSERT INTO versions (collection_id, semver, major, minor, patch, hash, public_hash, record_count, file_count, total_bytes, status, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, 'ready', $9) RETURNING id`, + [COL, semver, maj, min, pat, `private:${semver}`, `public:${semver}`, members.length, at], + ) + const id = (v.rows[0] as { id: number }).id + await q( + `INSERT INTO version_schemas (version_id, slug, schema_id) VALUES ($1, 'Author', $2)`, + [id, schemaId], + ) + for (const r of members) { + await q( + `INSERT INTO version_records (version_id, record_hash, record_id, type, private) VALUES ($1, $2, $3, 'Author', false)`, + [id, r.hash, r.id], + ) + } + } + const a = await record('a', 'A') + await version('v1.0.0', [a], '2026-01-01T00:00:00Z') + + const h = await harness() + const first = await migrateAll(db, h.ports) + await h.drain() + expect(first.versions).toBe(1) + + // v1 moves on: a new version, a renamed collection, a deleted user. + const b = await record('b', 'B') + await version('v1.1.0', [a, b], '2026-02-01T00:00:00Z') + await q(`UPDATE collections SET name = 'Lib 2' WHERE id = $1`, [COL]) + await q(`DELETE FROM "user" WHERE id = 'u2'`) + + const second = await migrateAll(db, h.ports, { sync: true }) + await h.drain() + expect(second).toMatchObject({ versions: 1, deleted: { user: 1 }, removedInV1: [] }) + const users = await h.ports.db.select().from(dbSchema.user) + expect(users.map((u) => u.id)).toEqual(['u1']) + const [lib] = await h.ports.db.select().from(dbSchema.collections) + expect(lib!.name).toBe('Lib 2') + const versions = (await ( + await h.request('/api/collections/org/lib/versions', { user: 'u1' }) + ).json()) as { semver: string; recordCount: number }[] + expect(versions.map((v) => [v.semver, v.recordCount])).toEqual([ + ['v1.1.0', 2], + ['v1.0.0', 1], + ]) + const repo = await h.ports.stores.forCollection(COL) + const { entries } = await verifyLog(repo, COL, [h.signer.publicKey]) + expect(entries.map((e) => e.semver)).toEqual(['v1.0.0', 'v1.1.0']) + + // Nothing new: nothing replayed, nothing deleted. + const third = await migrateAll(db, h.ports, { sync: true }) + expect(third).toMatchObject({ versions: 0, deleted: {} }) + }) +}) diff --git a/packages/migrate/test/files.test.ts b/packages/migrate/test/files.test.ts new file mode 100644 index 0000000..2234fb3 --- /dev/null +++ b/packages/migrate/test/files.test.ts @@ -0,0 +1,64 @@ +/** Copying v1 file objects to their canonical keys in the deployment's bucket. */ +import { createHash } from 'node:crypto' + +import { memoryStore } from '@underlay/protocol' +import { dbSchema as schema } from '@underlay/server' +import { afterAll, describe, expect, it } from 'vitest' + +import { cleanup, harness } from '../../server/test/harness.js' +import { copyFiles } from '../src/files.js' + +afterAll(cleanup) + +const sha = (s: string) => createHash('sha256').update(s).digest('hex') +const v1Key = (h: string) => `files/${h.slice(0, 2)}/${h.slice(2, 4)}/${h}` + +describe('copyFiles', () => { + it('copies verified bytes, repoints rows, reports the rest, and re-runs cleanly', async () => { + const h = await harness() + const source = memoryStore() + const files = { + good: 'cover bytes', + missing: 'never uploaded', + wrong: 'expected bytes', + present: 'already copied', + } + for (const [name, body] of Object.entries(files)) { + await h.ports.db.insert(schema.files).values({ + hash: sha(body), + size: body.length, + mimeType: 'text/plain', + storageKey: v1Key(sha(body)), + }) + if (name === 'good') await source.put(v1Key(sha(body)), body) + if (name === 'wrong') await source.put(v1Key(sha(body)), 'tampered bytes') + } + const canonical = (body: string) => h.ports.stores.canonicalFileKey(sha(body)) + await h.ports.stores.fileBytes.put(canonical(files.present), files.present) + + const report = await copyFiles(source, h.ports, { concurrency: 2 }) + expect(report).toMatchObject({ + copied: 1, + present: 1, + bytes: files.good.length, + missing: [sha(files.missing)], + mismatched: [sha(files.wrong)], + tooLarge: [], + }) + expect(await (await h.ports.stores.fileBytes.get(canonical(files.good)))!.text()).toBe( + files.good, + ) + const keys = Object.fromEntries( + (await h.ports.db.select().from(schema.files)).map((f) => [f.hash, f.storageKey]), + ) + expect(keys[sha(files.good)]).toBe(canonical(files.good)) + expect(keys[sha(files.present)]).toBe(canonical(files.present)) + expect(keys[sha(files.missing)]).toBe(v1Key(sha(files.missing))) + + // Moved rows are skipped; only the failures are tried again. + const again = await copyFiles(source, h.ports) + expect(again).toMatchObject({ copied: 0, present: 0 }) + expect(again.missing).toHaveLength(1) + expect(again.mismatched).toHaveLength(1) + }) +}) diff --git a/src/db/migrations/0000_thin_scream.sql b/packages/migrate/test/v1-schema/0000_thin_scream.sql similarity index 100% rename from src/db/migrations/0000_thin_scream.sql rename to packages/migrate/test/v1-schema/0000_thin_scream.sql diff --git a/src/db/migrations/0001_aspiring_network.sql b/packages/migrate/test/v1-schema/0001_aspiring_network.sql similarity index 100% rename from src/db/migrations/0001_aspiring_network.sql rename to packages/migrate/test/v1-schema/0001_aspiring_network.sql diff --git a/src/db/migrations/0002_cute_lady_vermin.sql b/packages/migrate/test/v1-schema/0002_cute_lady_vermin.sql similarity index 100% rename from src/db/migrations/0002_cute_lady_vermin.sql rename to packages/migrate/test/v1-schema/0002_cute_lady_vermin.sql diff --git a/src/db/migrations/0003_right_hellcat.sql b/packages/migrate/test/v1-schema/0003_right_hellcat.sql similarity index 100% rename from src/db/migrations/0003_right_hellcat.sql rename to packages/migrate/test/v1-schema/0003_right_hellcat.sql diff --git a/src/db/migrations/0004_fancy_nightcrawler.sql b/packages/migrate/test/v1-schema/0004_fancy_nightcrawler.sql similarity index 100% rename from src/db/migrations/0004_fancy_nightcrawler.sql rename to packages/migrate/test/v1-schema/0004_fancy_nightcrawler.sql diff --git a/src/db/migrations/0005_instance_settings.sql b/packages/migrate/test/v1-schema/0005_instance_settings.sql similarity index 100% rename from src/db/migrations/0005_instance_settings.sql rename to packages/migrate/test/v1-schema/0005_instance_settings.sql diff --git a/src/db/migrations/0006_moaning_mauler.sql b/packages/migrate/test/v1-schema/0006_moaning_mauler.sql similarity index 100% rename from src/db/migrations/0006_moaning_mauler.sql rename to packages/migrate/test/v1-schema/0006_moaning_mauler.sql diff --git a/src/db/migrations/0007_lucky_rhino.sql b/packages/migrate/test/v1-schema/0007_lucky_rhino.sql similarity index 100% rename from src/db/migrations/0007_lucky_rhino.sql rename to packages/migrate/test/v1-schema/0007_lucky_rhino.sql diff --git a/src/db/migrations/0008_mixed_moonstone.sql b/packages/migrate/test/v1-schema/0008_mixed_moonstone.sql similarity index 100% rename from src/db/migrations/0008_mixed_moonstone.sql rename to packages/migrate/test/v1-schema/0008_mixed_moonstone.sql diff --git a/src/db/migrations/0009_boring_tiger_shark.sql b/packages/migrate/test/v1-schema/0009_boring_tiger_shark.sql similarity index 100% rename from src/db/migrations/0009_boring_tiger_shark.sql rename to packages/migrate/test/v1-schema/0009_boring_tiger_shark.sql diff --git a/src/db/migrations/0010_unique_nemesis.sql b/packages/migrate/test/v1-schema/0010_unique_nemesis.sql similarity index 100% rename from src/db/migrations/0010_unique_nemesis.sql rename to packages/migrate/test/v1-schema/0010_unique_nemesis.sql diff --git a/src/db/migrations/0011_wealthy_hardball.sql b/packages/migrate/test/v1-schema/0011_wealthy_hardball.sql similarity index 100% rename from src/db/migrations/0011_wealthy_hardball.sql rename to packages/migrate/test/v1-schema/0011_wealthy_hardball.sql diff --git a/src/db/migrations/0012_sloppy_nightcrawler.sql b/packages/migrate/test/v1-schema/0012_sloppy_nightcrawler.sql similarity index 100% rename from src/db/migrations/0012_sloppy_nightcrawler.sql rename to packages/migrate/test/v1-schema/0012_sloppy_nightcrawler.sql diff --git a/src/db/migrations/0013_windy_blue_marvel.sql b/packages/migrate/test/v1-schema/0013_windy_blue_marvel.sql similarity index 100% rename from src/db/migrations/0013_windy_blue_marvel.sql rename to packages/migrate/test/v1-schema/0013_windy_blue_marvel.sql diff --git a/src/db/migrations/0014_fantastic_skrulls.sql b/packages/migrate/test/v1-schema/0014_fantastic_skrulls.sql similarity index 100% rename from src/db/migrations/0014_fantastic_skrulls.sql rename to packages/migrate/test/v1-schema/0014_fantastic_skrulls.sql diff --git a/src/db/migrations/0015_broken_wrecking_crew.sql b/packages/migrate/test/v1-schema/0015_broken_wrecking_crew.sql similarity index 100% rename from src/db/migrations/0015_broken_wrecking_crew.sql rename to packages/migrate/test/v1-schema/0015_broken_wrecking_crew.sql diff --git a/src/db/migrations/0016_harsh_shooting_star.sql b/packages/migrate/test/v1-schema/0016_harsh_shooting_star.sql similarity index 100% rename from src/db/migrations/0016_harsh_shooting_star.sql rename to packages/migrate/test/v1-schema/0016_harsh_shooting_star.sql diff --git a/src/db/migrations/0017_version_file_refs.sql b/packages/migrate/test/v1-schema/0017_version_file_refs.sql similarity index 100% rename from src/db/migrations/0017_version_file_refs.sql rename to packages/migrate/test/v1-schema/0017_version_file_refs.sql diff --git a/src/db/migrations/meta/_journal.json b/packages/migrate/test/v1-schema/meta/_journal.json similarity index 100% rename from src/db/migrations/meta/_journal.json rename to packages/migrate/test/v1-schema/meta/_journal.json diff --git a/packages/migrate/tsconfig.json b/packages/migrate/tsconfig.json new file mode 100644 index 0000000..8c8d80a --- /dev/null +++ b/packages/migrate/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "target": "ES2024", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/packages/migrate/vitest.config.ts b/packages/migrate/vitest.config.ts new file mode 100644 index 0000000..3e42797 --- /dev/null +++ b/packages/migrate/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}) diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 0000000..520a8ee --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,34 @@ +{ + "name": "@underlay/protocol", + "version": "0.0.0", + "private": true, + "description": "Underlay protocol v2: the format (canonical JSON, hashing, input rules, validation, content-addressed trees, version roots), the repository layout and signed version log, and the stores that hold them.", + "type": "module", + "sideEffects": false, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit", + "vectors": "tsx scripts/gen-vectors.ts", + "check-browser": "tsx scripts/browser-bundle.ts" + }, + "dependencies": { + "@cfworker/json-schema": "^4.1.1", + "@noble/hashes": "^2.2.0", + "aws4fetch": "^1.0.20" + }, + "devDependencies": { + "@hyperjump/json-schema": "^1.17.9", + "@types/node": "^25.0.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "esbuild": "0.27.7", + "fast-check": "^4.3.0", + "miniflare": "5.20261001.0-alpha", + "tsx": "^4.19.0", + "typescript": "^6.0.0", + "vitest": "^4.1.6" + } +} diff --git a/packages/protocol/scripts/browser-bundle.ts b/packages/protocol/scripts/browser-bundle.ts new file mode 100644 index 0000000..d3d00fa --- /dev/null +++ b/packages/protocol/scripts/browser-bundle.ts @@ -0,0 +1,39 @@ +/** + * Build @underlay/protocol for the browser: one ESM bundle of src/index.ts with + * esbuild, `platform: 'browser'`. Any import a browser can't satisfy (a `node:` + * module, even a lazy one) fails the build. Used by the browser test and by + * `pnpm --filter @underlay/protocol check-browser`. + */ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { gzipSync } from 'node:zlib' + +import { build } from 'esbuild' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +/** `iife` exposes the package as the global `underlay`, for running it in a VM. */ +export async function browserBundle( + format: 'esm' | 'iife' = 'esm', +): Promise<{ code: string; bytes: number; gzip: number }> { + const out = await build({ + entryPoints: [resolve(root, 'src/index.ts')], + bundle: true, + platform: 'browser', + format, + ...(format === 'iife' ? { globalName: 'underlay' } : {}), + target: 'es2022', + minify: true, + write: false, + logLevel: 'silent', + }) + const code = out.outputFiles[0]!.text + return { code, bytes: code.length, gzip: gzipSync(code).length } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const { bytes, gzip } = await browserBundle() + console.log( + `browser bundle ok: ${(bytes / 1024).toFixed(0)} KB, ${(gzip / 1024).toFixed(0)} KB gzipped`, + ) +} diff --git a/packages/protocol/scripts/diff-validators.ts b/packages/protocol/scripts/diff-validators.ts new file mode 100644 index 0000000..99e0f1d --- /dev/null +++ b/packages/protocol/scripts/diff-validators.ts @@ -0,0 +1,855 @@ +/** + * Differential test: v1's validator (AJV, configured exactly as the v1 server) + * against the v2 validator (src/validate.ts), over every public schema and + * record on underlay.org. @hyperjump/json-schema (draft-07) runs alongside as a + * second interpreting validator. + * + * npx tsx packages/protocol/scripts/diff-validators.ts [--data ] [--offline] + * + * Three passes: + * 1. every distinct schema of every version is compiled by all three; + * 2. every record of each collection's latest version is validated; + * 3. production records only exercise the "valid" path (v1 refused the rest + * at push time), so a sample of each type's records is mutated against its + * schema (wrong types, missing required fields, extra fields, bad format + * and enum values) and validated again; and a corpus of edge-case strings + * is run through every format keyword. + * + * Downloads are read-only GETs to the public API, sent one at a time under the + * anonymous rate limit, and cached under --data so reruns are offline. + */ +import { + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { createInterface } from 'node:readline' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' + +import { Ajv } from 'ajv' +import addFormatsModule from 'ajv-formats' + +import { compileSchema, SchemaError } from '../src/validate.js' + +const API = 'https://www.underlay.org/api' +const args = process.argv.slice(2) +const option = (name: string, fallback: string) => { + const i = args.indexOf(name) + return i >= 0 && args[i + 1] ? args[i + 1]! : fallback +} +const DATA = option('--data', '/Users/travis/.claude-kf/jobs/71495a02/tmp/validator-data') +const OFFLINE = args.includes('--offline') +/** Validate only the mutation sample of production records (for iterating). */ +const QUICK = args.includes('--quick') +/** Records per type that pass 3 mutates. */ +const MUTATE_SAMPLE = 20 +const MUTATE_MAX_BYTES = 64 * 1024 +const MAX_LISTED = 40 + +// --- Download (cached) ---------------------------------------------------------- + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +let lastRequest = 0 + +async function get(url: string): Promise { + if (OFFLINE) throw new Error(`--offline and not cached: ${url}`) + // One request at a time, under the anonymous limit of 60 a minute: this is a + // production site. + for (let attempt = 0; ; attempt++) { + const wait = lastRequest + 1100 - Date.now() + if (wait > 0) await sleep(wait) + lastRequest = Date.now() + const res = await fetch(url, { + headers: { 'user-agent': 'underlay-validator-diff (read-only)' }, + }) + if (res.status === 429 && attempt < 5) { + await sleep(1000 * (Number(res.headers.get('retry-after')) || 60)) + continue + } + if (!res.ok) throw new Error(`GET ${url}: ${res.status}`) + return res + } +} + +async function cachedJson(file: string, url: string): Promise { + const path = join(DATA, file) + if (existsSync(path)) return JSON.parse(readFileSync(path, 'utf8')) as T + const body = await (await get(url)).text() + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, body) + return JSON.parse(body) as T +} + +async function cachedFile(file: string, url: string): Promise { + const path = join(DATA, file) + if (existsSync(path)) return path + const res = await get(url) + mkdirSync(join(path, '..'), { recursive: true }) + await pipeline(Readable.fromWeb(res.body as never), createWriteStream(path + '.part')) + renameSync(path + '.part', path) + return path +} + +interface CollectionRow { + slug: string + ownerSlug: string + latestVersion: string | null +} + +async function listCollections(): Promise { + const out: CollectionRow[] = [] + const limit = 100 + for (let page = 0; ; page++) { + const body = await cachedJson<{ collections: CollectionRow[] }>( + `collections-p${page}.json`, + `${API}/collections?limit=${limit}&offset=${page * limit}`, + ) + out.push(...body.collections) + if (body.collections.length < limit) break + } + return out +} + +// --- The three validators --------------------------------------------------------- + +/** A compiled validator returning error strings (empty = valid), or the compile error. */ +type Compiled = ((data: unknown) => string[]) | string + +// v1, exactly: src/lib/core/validate.ts on main. +const addFormats = addFormatsModule as unknown as (ajv: Ajv) => void +const ajv = new Ajv({ allErrors: true, strict: false }) +addFormats(ajv) +// AJV warns about (and ignores) unknown formats; keep the warnings out of the report. +const ajvWarnings = new Set() +;(ajv as unknown as { logger: unknown }).logger = { + log: () => {}, + warn: (msg: unknown) => ajvWarnings.add(String(msg)), + error: console.error, +} + +function ajvCompile(schema: unknown): Compiled { + try { + const v = ajv.compile(schema as object) + ajv.removeSchema(schema as object) + return (data) => + v(data) ? [] : (v.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`) + } catch (err) { + return (err as Error).message + } +} + +function v2Compile(schema: unknown): Compiled { + try { + return compileSchema(schema) + } catch (err) { + return err instanceof SchemaError ? err.message : `THROWN: ${(err as Error).message}` + } +} + +// @hyperjump is async to compile and keeps a global registry keyed by URI. +let hj: typeof import('@hyperjump/json-schema/draft-07') | null = null +let hjCounter = 0 +async function hjCompile(schema: unknown): Promise { + if (!hj) { + hj = await import('@hyperjump/json-schema/draft-07') + // Side effect only (registers the format handlers); it ships no types. + const formats = '@hyperjump/json-schema/formats' + await import(formats) + hj.setShouldValidateFormat(true) + } + const uri = `https://diff.invalid/schema/${hjCounter++}` + try { + hj.registerSchema( + structuredClone(schema) as never, + uri, + 'http://json-schema.org/draft-07/schema', + ) + const v = await hj.validate(uri) + return (data) => (v(data as never).valid ? [] : ['invalid']) + } catch (err) { + return (err as Error).message.slice(0, 300) + } +} + +interface Trio { + ajv: Compiled + v2: Compiled + hj: Compiled +} + +async function compileAll(schema: unknown): Promise { + return { ajv: ajvCompile(schema), v2: v2Compile(schema), hj: await hjCompile(schema) } +} + +// --- Comparison ------------------------------------------------------------------- + +const sameSet = (a: string[], b: string[]) => { + const x = [...a].sort() + const y = [...b].sort() + return x.length === y.length && x.every((v, i) => v === y[i]) +} + +class Tally { + checked = 0 + agreeValid = 0 + agreeInvalid = 0 + sameMessages = 0 + /** v2 reported some of AJV's messages: it short-circuits, AJV ran with allErrors. */ + subsetMessages = 0 + hjChecked = 0 + hjAgree = 0 + disagreements: string[] = [] + messageDiffs: string[] = [] + hjDisagreements: string[] = [] + /** Schemas both refused to compile, with both messages. */ + bothRejected: string[] = [] + + constructor(readonly name: string) {} + + compare(label: string, t: Trio, data: unknown): void { + if (typeof t.ajv === 'string' || typeof t.v2 === 'string') return // reported at compile + this.checked++ + const ae = t.ajv(data) + const be = t.v2(data) + const aValid = ae.length === 0 + const bValid = be.length === 0 + const show = (e: string[]) => (e.length ? JSON.stringify(e) : 'valid') + if (aValid !== bValid) { + this.disagreements.push(` ${label}\n ajv: ${show(ae)}\n v2: ${show(be)}`) + } else if (aValid) { + this.agreeValid++ + } else { + this.agreeInvalid++ + if (sameSet(ae, be)) this.sameMessages++ + else if (be.every((m) => ae.includes(m))) this.subsetMessages++ + else this.messageDiffs.push(` ${label}\n ajv: ${show(ae)}\n v2: ${show(be)}`) + } + if (typeof t.hj !== 'string') { + this.hjChecked++ + const hValid = t.hj(data).length === 0 + if (hValid === aValid) this.hjAgree++ + else + this.hjDisagreements.push( + ` ${label}: ajv ${show(ae)}, hyperjump ${hValid ? 'valid' : 'invalid'}`, + ) + } + } + + print(): void { + const list = (lines: string[]) => { + for (const line of lines.slice(0, MAX_LISTED)) console.log(line) + if (lines.length > MAX_LISTED) console.log(` … and ${lines.length - MAX_LISTED} more`) + } + console.log(`\n${this.name}: ${this.checked} checked`) + console.log(` agree valid: ${this.agreeValid}`) + console.log( + ` agree invalid: ${this.agreeInvalid} (identical messages: ${this.sameMessages}, ` + + `v2's a subset of AJV's: ${this.subsetMessages})`, + ) + console.log(` disagree: ${this.disagreements.length}`) + list(this.disagreements) + if (this.messageDiffs.length) { + console.log(` verdicts agree, messages differ: ${this.messageDiffs.length}`) + list(this.messageDiffs) + } + if (this.bothRejected.length) { + console.log(` schemas both refused to compile: ${this.bothRejected.length}`) + list(this.bothRejected) + } + console.log(` hyperjump agrees with ajv on ${this.hjAgree}/${this.hjChecked}`) + list(this.hjDisagreements) + } +} + +// --- Mutations (pass 3) ----------------------------------------------------------- + +const FORMAT_CORPUS = [ + '', + 'x', + '2020-01-01', + '2020-02-29', + '2021-02-29', + '2020-13-01', + '20200101', + '12:00:00', + '12:00:00Z', + '12:00:00+01:00', + '23:59:60Z', + '2020-01-01T00:00:00', + '2020-01-01T00:00:00Z', + '2020-01-01T00:00:00.123Z', + '2020-01-01t00:00:00z', + '2020-01-01 00:00:00Z', + '2020-01-01T00:00:00+0100', + '2020-01-01T00:00:00+01:00', + '2020-01-01T00:00:00+25:00', + '2020-01-01T00:00:00+01:99', + '2020-12-31T23:59:60Z', + '2020-12-31T22:59:60-01:00', + '2020-12-31T12:59:60Z', + '2020-02-30T00:00:00Z', + 'a@b', + 'a@b.co', + 'a.b+c@example.org', + '"a b"@example.org', + 'a..b@example.org', + '.a@example.org', + 'a@-example.org', + 'a@exa_mple.org', + `${'a'.repeat(65)}@example.org`, + `a@${'b'.repeat(64)}.org`, + 'user@[127.0.0.1]', + 'http://example.com', + 'https://example.com/a b', + 'https://example.com/a%20b?q=1#f', + 'https://例子.测试/', + 'HTTP://EXAMPLE.COM/%zz', + 'example.com', + '/relative/path', + '#frag', + 'urn:isbn:0451450523', + 'mailto:a@example.org', + 'doi:10.1000/182', + 'http://[::1]/', + 'ftp://ftp.example.com/file', + '550e8400-e29b-41d4-a716-446655440000', + 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', + '550E8400-E29B-41D4-A716-446655440000', + '550e8400e29b41d4a716446655440000', + '127.0.0.1', + '001.002.003.004', + '256.0.0.1', + '::1', + 'example', + 'ex_ample.com', + '-example.com', + 'P1D', + 'P1.5D', + 'PT', + '/a/b~0c', + '/a~2', + '0/a', + '^[a-z]+$', + '(', + '\\p{L}', + 'a\\Z', + 'QUJD', + 'QUJ', +] +const ALL_FORMATS = [ + 'date', + 'time', + 'date-time', + 'iso-time', + 'iso-date-time', + 'duration', + 'uri', + 'uri-reference', + 'uri-template', + 'url', + 'email', + 'hostname', + 'ipv4', + 'ipv6', + 'regex', + 'uuid', + 'json-pointer', + 'json-pointer-uri-fragment', + 'relative-json-pointer', + 'byte', + 'int32', + 'int64', + 'float', + 'double', + 'password', + 'binary', +] + +const isObject = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v) + +function wrongType(v: unknown): unknown { + if (typeof v === 'string') return 12345 + if (typeof v === 'number') return Number.isInteger(v) ? 1.5 : 'x' + if (Array.isArray(v)) return {} + if (isObject(v)) return [] + return 'x' +} + +/** Labelled variants of `data` that probe the keywords its schema uses. */ +function* mutations(schema: unknown, data: unknown): Generator<[string, unknown]> { + if (!isObject(data)) { + yield ['wrong type', wrongType(data)] + return + } + yield ['extra field', { ...data, __extra: 1 }] + yield ['not an object', [data]] + if (!isObject(schema)) return + for (const k of Array.isArray(schema.required) ? (schema.required as string[]) : []) { + if (!(k in data)) continue + const copy = { ...data } + delete copy[k] + yield [`drop required ${k}`, copy] + } + const props = isObject(schema.properties) ? schema.properties : {} + for (const [k, sub] of Object.entries(props)) { + if (!(k in data)) continue + yield [`${k}: wrong type`, { ...data, [k]: wrongType(data[k]) }] + yield [`${k}: null`, { ...data, [k]: null }] + if (!isObject(sub)) continue + if (typeof sub.format === 'string' || typeof sub.pattern === 'string') { + for (const s of FORMAT_CORPUS) yield [`${k}: ${JSON.stringify(s)}`, { ...data, [k]: s }] + } + if (Array.isArray(sub.enum)) yield [`${k}: not in enum`, { ...data, [k]: '__not_in_enum__' }] + const v = data[k] + if (Array.isArray(v) && v.length > 0) { + yield [`${k}[0]: wrong type`, { ...data, [k]: [wrongType(v[0]), ...v.slice(1)] }] + if (isObject(v[0])) { + for (const ik of Object.keys(v[0])) { + const item = { ...v[0], [ik]: wrongType(v[0][ik]) } + yield [`${k}[0].${ik}: wrong type`, { ...data, [k]: [item, ...v.slice(1)] }] + } + } + } + if (isObject(v)) { + for (const ik of Object.keys(v)) { + yield [`${k}.${ik}: wrong type`, { ...data, [k]: { ...v, [ik]: wrongType(v[ik]) } }] + } + yield [`${k}: extra field`, { ...data, [k]: { ...v, __extra: 1 } }] + } + } +} + +// --- Survey ----------------------------------------------------------------------- + +const LATER_KEYWORDS = new Set([ + '$recursiveRef', + '$recursiveAnchor', + '$anchor', + '$defs', + '$vocabulary', + '$dynamicRef', + '$dynamicAnchor', + 'unevaluatedProperties', + 'unevaluatedItems', + 'dependentRequired', + 'dependentSchemas', + 'prefixItems', + 'minContains', + 'maxContains', +]) +const DATA_KEYWORDS = new Set(['enum', 'const', 'default', 'examples', 'required']) +const MAP_KEYWORDS = new Set([ + 'properties', + 'patternProperties', + 'definitions', + '$defs', + 'dependencies', +]) + +const count = (m: Map, k: string) => m.set(k, (m.get(k) ?? 0) + 1) + +interface Survey { + $schema: Map + formats: Map + keywords: Map + refs: Map + refWithSiblings: number + privateFields: number +} + +function surveySchema(node: unknown, s: Survey, root = true): void { + if (Array.isArray(node)) { + for (const item of node) surveySchema(item, s, false) + return + } + if (!isObject(node)) return + if (root) count(s.$schema, typeof node.$schema === 'string' ? node.$schema : '(none)') + if (typeof node.format === 'string') count(s.formats, node.format) + if (typeof node.$ref === 'string') { + const r = node.$ref + count( + s.refs, + r.startsWith('#/definitions/') + ? '#/definitions/…' + : r.startsWith('#/$defs/') + ? '#/$defs/…' + : r, + ) + if (Object.keys(node).some((k) => !['$ref', 'description', 'title'].includes(k))) { + s.refWithSiblings++ + } + } + for (const [k, v] of Object.entries(node)) { + count(s.keywords, k) + if (DATA_KEYWORDS.has(k)) continue + if (MAP_KEYWORDS.has(k) && isObject(v)) { + for (const sub of Object.values(v)) { + if (k === 'properties' && isObject(sub) && sub.private === true) s.privateFields++ + surveySchema(sub, s, false) + } + } else { + surveySchema(v, s, false) + } + } +} + +const sortedCounts = (m: Map) => + [...m.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)) + +// --- Main ------------------------------------------------------------------------- + +async function main() { + mkdirSync(DATA, { recursive: true }) + const collections = await listCollections() + console.log(`collections listed: ${collections.length}`) + + // Every distinct schema (by content) across every version, and where it was seen. + const schemas = new Map() + const latest: { key: string; semver: string; schemas: Record }[] = [] + let versionCount = 0 + for (const c of collections) { + const key = `${c.ownerSlug}/${c.slug}` + if (!c.latestVersion) { + console.log(` ${key}: no versions, skipped`) + continue + } + const base = `${API}/collections/${key}` + const versions = await cachedJson<{ semver: string }[]>( + `${key}/versions.json`, + `${base}/versions`, + ) + for (const v of versions) { + versionCount++ + const detail = await cachedJson<{ schemas?: Record }>( + `${key}/versions/${v.semver}.json`, + `${base}/versions/${v.semver}`, + ) + for (const [slug, schema] of Object.entries(detail.schemas ?? {})) { + const id = JSON.stringify(schema) + const entry = schemas.get(id) ?? { schema, seenIn: [] } + entry.seenIn.push(`${key}@${v.semver}:${slug}`) + schemas.set(id, entry) + } + if (v.semver === c.latestVersion) { + latest.push({ key, semver: v.semver, schemas: detail.schemas ?? {} }) + } + } + } + + const survey: Survey = { + $schema: new Map(), + formats: new Map(), + keywords: new Map(), + refs: new Map(), + refWithSiblings: 0, + privateFields: 0, + } + for (const { schema } of schemas.values()) surveySchema(schema, survey) + + const started = Date.now() + const progress = (what: string) => + process.stderr.write(`[${((Date.now() - started) / 1000).toFixed(1)}s] ${what}\n`) + progress('pass 1: compile') + // Pass 1: compile. + const compileReport: string[] = [] + let compileAgree = 0 + for (const { schema, seenIn } of schemas.values()) { + const t = await compileAll(schema) + const ok = [t.ajv, t.v2, t.hj].map((c) => typeof c !== 'string') + if (ok[0] === ok[1] && ok[0] === ok[2]) compileAgree++ + if (ok.includes(false)) { + const show = (c: Compiled) => (typeof c === 'string' ? c : 'ok') + compileReport.push( + ` ${seenIn[0]}${seenIn.length > 1 ? ` (+${seenIn.length - 1} more)` : ''}\n` + + ` ajv: ${show(t.ajv)}\n v2: ${show(t.v2)}\n hyperjump: ${show(t.hj)}`, + ) + } + } + + // Passes 2 and 3: records of each latest version, and mutations of a sample. + const production = new Tally('production records') + const mutated = new Tally('mutated records') + for (const { key, semver, schemas: typeSchemas } of latest) { + progress(`pass 2/3: ${key}@${semver}`) + const byType = new Map() + for (const [slug, schema] of Object.entries(typeSchemas)) + byType.set(slug, await compileAll(schema)) + const sampled = new Map() + const file = await cachedFile( + `${key}/records-${semver}.ndjson`, + `${API}/collections/${key}/versions/${semver}/records.ndjson`, + ) + const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity }) + for await (const line of lines) { + if (!line) continue + const rec = JSON.parse(line) as { id: string; type: string; data: unknown } + const t = byType.get(rec.type) + if (!t) throw new Error(`${key}@${semver}: no schema for type ${rec.type}`) + const label = `${key}@${semver} ${rec.type} ${JSON.stringify(rec.id)}` + const n = sampled.get(rec.type) ?? 0 + if (QUICK && n >= MUTATE_SAMPLE) continue + production.compare(label, t, rec.data) + // Large records are skipped: AJV takes seconds per call on some of them. + if (n < MUTATE_SAMPLE && line.length < MUTATE_MAX_BYTES) { + sampled.set(rec.type, n + 1) + for (const [what, data] of mutations(typeSchemas[rec.type], rec.data)) { + mutated.compare(`${label} [${what}]`, t, data) + } + } + } + } + + progress('pass 3b: format corpus') + // Pass 3b: every format keyword over the corpus, on strings and on numbers. + const formatTally = new Tally('format corpus') + for (const f of ALL_FORMATS) { + const t = await compileAll({ format: f }) + for (const s of [...FORMAT_CORPUS, 2 ** 31, 1.5, -1]) { + formatTally.compare(`format ${f}: ${JSON.stringify(s)}`, t, s) + } + } + + progress('pass 3c: keyword corpus') + // Pass 3c: every draft-07 keyword, and a few schema shapes, over small instances. + const keywordTally = new Tally('keyword corpus') + for (const [schema, instances] of KEYWORD_CORPUS) { + const t = await compileAll(schema) + const show = (c: Compiled) => (typeof c === 'string' ? c : 'ok') + const failed = [t.ajv, t.v2].map((c) => typeof c === 'string') + if (failed[0] !== failed[1]) { + keywordTally.disagreements.push( + ` compile ${JSON.stringify(schema)}\n ajv: ${show(t.ajv)}\n v2: ${show(t.v2)}`, + ) + } else if (failed[0]) { + keywordTally.bothRejected.push( + ` ${JSON.stringify(schema)}\n ajv: ${show(t.ajv)}\n v2: ${show(t.v2)}`, + ) + } + if (failed[0] || failed[1]) continue + for (const data of instances) + keywordTally.compare(`${JSON.stringify(schema)} ← ${JSON.stringify(data)}`, t, data) + } + + // --- Report --------------------------------------------------------------------- + console.log(`versions: ${versionCount} (latest: ${latest.length})`) + console.log(`distinct schemas: ${schemas.size}`) + console.log(`\n$schema values (distinct schemas):`) + for (const [k, n] of sortedCounts(survey.$schema)) console.log(` ${n}\t${k}`) + console.log(`formats (occurrences):`) + for (const [k, n] of sortedCounts(survey.formats)) console.log(` ${n}\t${k}`) + console.log(`$ref targets (occurrences):`) + for (const [k, n] of sortedCounts(survey.refs)) console.log(` ${n}\t${k}`) + console.log(`$ref with sibling keywords: ${survey.refWithSiblings}`) + console.log(`field-level "private": true: ${survey.privateFields}`) + const later = sortedCounts(survey.keywords).filter(([k]) => LATER_KEYWORDS.has(k)) + console.log( + `2019-09+ keywords: ${later.length ? later.map(([k, n]) => `${k}=${n}`).join(', ') : 'none'}`, + ) + console.log(`all keys in schema positions (occurrences):`) + console.log( + ' ' + + sortedCounts(survey.keywords) + .map(([k, n]) => `${k}=${n}`) + .join(', '), + ) + if (ajvWarnings.size) console.log(`AJV warnings: ${[...ajvWarnings].join(' | ')}`) + + console.log(`\nschema compile: ${compileAgree}/${schemas.size} agree (ajv, v2, hyperjump)`) + for (const line of compileReport) console.log(line) + + production.print() + mutated.print() + formatTally.print() + keywordTally.print() +} + +/** [schema, instances]: each instance is validated by all three. */ +const KEYWORD_CORPUS: [unknown, unknown[]][] = [ + [true, [1, null]], + [false, [1]], + [{}, [1, 'a', null, [], {}]], + [{ type: 'integer' }, [1, 1.0, 1.5, '1', 2 ** 53, -0]], + [{ type: ['string', 'null'] }, ['a', null, 1]], + [{ type: 'number' }, [1, 'x']], + [{ enum: [1, 'a', null, [1], { a: 1 }] }, [1, 'a', null, [1], { a: 1 }, 2, [2], { a: 2 }, true]], + [{ const: { a: [1, 2] } }, [{ a: [1, 2] }, { a: [2, 1] }]], + [{ const: 1 }, [1, 1.0, '1']], + [{ multipleOf: 0.01 }, [0.07, 0.1, 1.23, 19.99]], + [{ multipleOf: 3 }, [9, 10, 4.5]], + [{ minimum: 1, maximum: 3 }, [0, 1, 3, 4, 'x']], + [{ exclusiveMinimum: 1, exclusiveMaximum: 3 }, [1, 2, 3]], + [{ minLength: 2, maxLength: 3 }, ['a', 'ab', 'abcd', '😀', '😀😀', 'é']], + [{ pattern: '^a' }, ['abc', 'bac', 1]], + [{ pattern: '\\p{Lu}' }, ['A', 'a']], + [{ pattern: '^.$' }, ['😀']], + [{ items: { type: 'string' } }, [['a'], ['a', 1, 2], 'x']], + [ + { items: [{ type: 'string' }, { type: 'number' }] }, + [ + ['a', 1], + [1, 'a'], + ['a', 1, null], + ], + ], + [{ items: [{ type: 'string' }], additionalItems: false }, [['a'], ['a', 1], ['a', 1, 2]]], + [ + { items: [{ type: 'string' }], additionalItems: { type: 'number' } }, + [ + ['a', 1], + ['a', 'b', 'c'], + ], + ], + [{ additionalItems: false }, [[1, 2]]], + [{ minItems: 1, maxItems: 2 }, [[], [1], [1, 2, 3]]], + [ + { uniqueItems: true }, + [ + [1, 2], + [1, 1], + [{ a: 1 }, { a: 1 }], + [1, 2, 1, 2], + [[1], [1]], + ], + ], + [{ contains: { type: 'string' } }, [[1, 'a'], [1, 2], []]], + [{ minProperties: 1, maxProperties: 2 }, [{}, { a: 1 }, { a: 1, b: 2, c: 3 }]], + [{ required: ['a', 'b'] }, [{ a: 1, b: 2 }, { a: 1 }, {}, []]], + [ + { properties: { a: { type: 'string' }, 'x/y~z': { type: 'number' } } }, + [{ a: 'x' }, { a: 1 }, { 'x/y~z': 'q' }], + ], + [ + { properties: { 'a b': { type: 'string' }, 'é%': { type: 'string' } } }, + [{ 'a b': 1, 'é%': 2 }], + ], + [{ patternProperties: { '^x-': { type: 'string' } } }, [{ 'x-a': 'ok', 'x-b': 1, y: 1 }]], + [{ properties: { a: {} }, additionalProperties: false }, [{ a: 1 }, { a: 1, b: 2, c: 3 }]], + [ + { properties: { a: {} }, patternProperties: { '^p': {} }, additionalProperties: false }, + [{ a: 1, p1: 2 }, { q: 1 }], + ], + [{ additionalProperties: { type: 'string' } }, [{ a: 'x', b: 1 }]], + [{ properties: { a: false } }, [{ a: 1 }, {}]], + [{ dependencies: { a: ['b', 'c'] } }, [{ a: 1 }, { a: 1, b: 1, c: 1 }, { b: 1 }]], + [{ dependencies: { a: { required: ['b'] } } }, [{ a: 1 }, { a: 1, b: 1 }]], + [{ propertyNames: { maxLength: 2 } }, [{ ab: 1 }, { abc: 1 }]], + [{ propertyNames: { pattern: '^[a-z]+$' } }, [{ A: 1, b: 2 }]], + [ + { if: { type: 'string' }, then: { minLength: 2 }, else: { type: 'number' } }, + ['a', 'ab', 1, null], + ], + [ + { if: { properties: { k: { const: 'x' } } }, then: { required: ['v'] } }, + [{ k: 'x' }, { k: 'y' }], + ], + [{ allOf: [{ type: 'string' }, { minLength: 2 }] }, ['a', 'ab', 1]], + [{ anyOf: [{ type: 'string' }, { type: 'number' }] }, ['a', 1, null]], + [{ oneOf: [{ type: 'integer' }, { type: 'number' }] }, [1, 1.5, 'x']], + [{ not: { type: 'string' } }, [1, 'a']], + [{ format: 'email' }, ['a@b.co', 'a@b', 5]], + [{ format: 'unknown-format' }, ['x']], + [{ type: 'object', 'x-ref-type': 'T', version: '1', private: true }, [{}, 1]], + [ + { definitions: { s: { type: 'string' } }, properties: { a: { $ref: '#/definitions/s' } } }, + [{ a: 'x' }, { a: 1 }], + ], + [{ $defs: { s: { type: 'string' } }, properties: { a: { $ref: '#/$defs/s' } } }, [{ a: 1 }]], + [ + { + definitions: { s: { type: 'string' } }, + properties: { a: { $ref: '#/definitions/s', maxLength: 1 } }, + }, + [{ a: 'xy' }], + ], + [ + { $ref: '#/definitions/d', definitions: { d: { type: 'object', required: ['t'] } } }, + [{ t: 1 }, {}], + ], + [{ properties: { a: { type: 'string' } }, items: { $ref: '#/properties/a' } }, [['x', 1]]], + [{ definitions: { 'a b': { type: 'string' } }, $ref: '#/definitions/a%20b' }, ['x', 1]], + [{ definitions: { 'a/b': { type: 'string' } }, $ref: '#/definitions/a~1b' }, ['x', 1]], + [ + { + $id: 'https://example.org/s', + definitions: { a: { type: 'string' } }, + $ref: 'https://example.org/s#/definitions/a', + }, + ['x', 1], + ], + [ + { + $id: 'https://example.org/root.json', + definitions: { a: { $id: 'a.json', type: 'string' } }, + $ref: 'a.json', + }, + ['x', 1], + ], + [{ definitions: { a: { $anchor: 'foo', type: 'string' } }, $ref: '#foo' }, ['x', 1]], + [ + { + definitions: { + n: { + type: 'object', + properties: { c: { type: 'array', items: { $ref: '#/definitions/n' } } }, + }, + }, + $ref: '#/definitions/n', + }, + [{ c: [{ c: [] }] }, { c: [{ c: [1] }] }], + ], + [{ id: 'other', definitions: { a: { type: 'string' } }, $ref: '#/definitions/a' }, ['x', 1]], + [{ unevaluatedProperties: false, properties: { a: {} } }, [{ b: 1 }]], + [{ dependentRequired: { a: ['b'] } }, [{ a: 1 }]], + [{ dependentSchemas: { a: { required: ['b'] } } }, [{ a: 1 }]], + [{ prefixItems: [{ type: 'string' }] }, [[1]]], + [{ contains: { type: 'string' }, minContains: 2, maxContains: 2 }, [['a'], ['a', 'b', 'c']]], + [{ $schema: 'http://json-schema.org/draft-07/schema' }, [1]], + [{ $schema: 'http://json-schema.org/draft-07/schema#' }, [1]], + [{ $schema: 'https://json-schema.org/draft-07/schema#' }, [1]], + [{ $schema: 'http://json-schema.org/draft-04/schema#' }, [1]], + [{ $schema: 'https://json-schema.org/draft/2020-12/schema' }, [1]], + [ + { properties: { a: { $schema: 'http://json-schema.org/draft-04/schema#', type: 'string' } } }, + [{ a: 1 }], + ], + [{ type: 'text' }, [1]], + [{ required: 'a' }, [1]], + [{ pattern: '(' }, [1]], + [{ pattern: '\\a' }, ['a']], + [{ patternProperties: { '(': {} } }, [1]], + [{ $ref: '#/definitions/missing' }, [1]], + [{ $ref: 'https://remote.invalid/schema.json' }, [1]], + [{ $ref: 'http://json-schema.org/draft-07/schema#' }, [{ type: 'string' }, { type: 3 }]], + [{ enum: [] }, [1]], + [{ minLength: -1 }, ['a']], + [{ multipleOf: 0 }, [1]], + [ + { properties: { a: { default: 'x', examples: ['y'], readOnly: true, $comment: 'c' } } }, + [{ a: 1 }], + ], + // Names that exist on Object.prototype are ordinary property names. + [{ required: ['toString'] }, [{}, { toString: 1 }]], + [{ properties: { constructor: { type: 'string' } } }, [{}, { constructor: 1 }]], + [{ dependencies: { toString: ['b'] } }, [{}]], + [{ format: 'hasOwnProperty' }, ['x']], + [ + JSON.parse('{"properties":{"__proto__":{"type":"string"}}}'), + [JSON.parse('{"__proto__":1}'), {}], + ], + // Every error, or the first per loop (schemas with branching keywords). + [{ properties: { a: { type: 'string' }, b: { type: 'string' } } }, [{ a: 1, b: 2 }]], + [ + { properties: { a: { type: 'string' }, b: { type: 'string' } }, not: { type: 'null' } }, + [{ a: 1, b: 2 }], + ], + [{ items: { type: 'string' }, anyOf: [{}] }, [[1, 2]]], +] + +await main() diff --git a/packages/protocol/scripts/experiment-chunking.ts b/packages/protocol/scripts/experiment-chunking.ts new file mode 100644 index 0000000..71a3044 --- /dev/null +++ b/packages/protocol/scripts/experiment-chunking.ts @@ -0,0 +1,184 @@ +/** + * Tree-parameter experiment: the protocol's fixed-probability chunker against a + * size-aware one (Dolt-style, Weibull-shaped split hazard). + * + * pnpm tsx scripts/experiment-chunking.ts [N] + * + * Boundaries depend on SHA-256 of keys, so leaf sizes are distributed the same + * way for any key set; synthetic ids are enough for shape. What real collections + * add (id lengths, record sizes) is measured separately. + * + * Reports, per chunker: leaf size spread, node counts per level, height, forced + * splits, and write amplification (nodes written) for single updates, single + * inserts, random batches and appends. + */ +import { createHash } from 'node:crypto' + +import { + buildTree, + type Change, + type Chunking, + compareUtf8, + MapSource, + mergeTree, + type NodeDesc, + protocolChunking, + type RecordEntry, + recordTree, + sha256Hex, + type TreeSink, +} from '../src/index.js' + +const N = Number(process.argv[2] ?? 1_000_000) + +/** Size-aware: split hazard from a Weibull(k=4) target with the given mean. */ +function sizeAware( + leafMean: number, + fanoutMean: number, + leafMax: number, + interiorMax: number, +): Chunking { + const k = 4 + const gamma = 0.9064024770554771 // Γ(1 + 1/4) + const hazard = (mean: number) => { + const lambda = mean / gamma + const table: number[] = [0] + for (let s = 1; s <= 8 * mean; s++) { + const a = Math.pow((s - 1) / lambda, k) + const b = Math.pow(s / lambda, k) + table.push(1 - Math.exp(-(b - a))) + } + return (s: number) => table[s] ?? 1 + } + const leafH = hazard(leafMean) + const nodeH = hazard(fanoutMean) + return { + name: `size-aware(${leafMean},${fanoutMean})`, + ends(level, u, size) { + if (level === 0 && size >= leafMax) return true + if (level > 0 && size >= interiorMax) return true + // A uniform draw per level from the key's hash (u is its first 8 bytes; + // derive more bits per level from it). + const d = createHash('sha256').update(u).update(String(level)).digest() + const r = d.readUInt32BE(0) / 2 ** 32 + return r < (level === 0 ? leafH(size) : nodeH(size)) + }, + } +} + +const idOf = (i: number) => `W${(i * 2654435761) % 4294967296}`.padEnd(12, 'x') + i +const entry = (key: string, v = 0): RecordEntry => ({ + key, + hash: sha256Hex(`${key}:${v}`), + size: 600, +}) + +class StatsSink implements TreeSink { + nodes = new Map() + leaves = new Map() + written = 0 + leafSizes: number[] = [] + perLevel: number[] = [] + forced = 0 + constructor(private readonly leafMax: number) {} + leaf(desc: NodeDesc, json: string, entries: readonly RecordEntry[]) { + this.written++ + this.nodes.set(desc.hash, json) + this.leaves.set(desc.hash, entries) + this.leafSizes.push(entries.length) + this.perLevel[0] = (this.perLevel[0] ?? 0) + 1 + if (entries.length >= this.leafMax) this.forced++ + } + interior(desc: NodeDesc, json: string) { + this.written++ + this.nodes.set(desc.hash, json) + this.perLevel[desc.level] = (this.perLevel[desc.level] ?? 0) + 1 + } +} + +const pct = (xs: number[], p: number) => + xs[Math.min(xs.length - 1, Math.floor((p / 100) * xs.length))]! + +async function run(chunking: Chunking, leafMax: number) { + const keys = Array.from({ length: N }, (_, i) => idOf(i)).sort(compareUtf8) + const sink = new StatsSink(leafMax) + const t0 = performance.now() + const root = buildTree( + recordTree, + sink, + keys.map((k) => entry(k)), + { chunking }, + )! + const buildMs = performance.now() - t0 + const sizes = sink.leafSizes.slice().sort((a, b) => a - b) + const perLevel = sink.perLevel.slice() + const forced = sink.forced + const source = new MapSource(recordTree, sink.nodes, sink.leaves) + + const amp = async (label: string, makeChanges: () => Change[], trials: number) => { + let total = 0 + for (let t = 0; t < trials; t++) { + const before = sink.written + await mergeTree(source, sink, root.hash, makeChanges(), { chunking }) + total += sink.written - before + } + return `${label}: ${(total / trials).toFixed(1)}` + } + let seed = 12345 + const rand = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648 + const pick = () => keys[Math.floor(rand() * keys.length)]! + const sortC = (cs: Change[]) => cs.sort((a, b) => compareUtf8(a.key, b.key)) + const results = [ + await amp( + '1 update', + () => + [{ key: pick(), entry: entry(pick(), 1) }].map((c) => ({ + key: c.key, + entry: entry(c.key, 1), + })), + 50, + ), + await amp( + '1 insert', + () => { + const k = `${pick()}~new` + return [{ key: k, entry: entry(k) }] + }, + 50, + ), + await amp('1 delete', () => [{ key: pick(), entry: null }], 50), + await amp( + '1k random updates', + () => { + const ks = new Set() + while (ks.size < 1000) ks.add(pick()) + return sortC([...ks].map((k) => ({ key: k, entry: entry(k, 2) }))) + }, + 5, + ), + await amp( + '10k appends', + () => + Array.from({ length: 10_000 }, (_, i) => `zzzz${String(i).padStart(6, '0')}`).map((k) => ({ + key: k, + entry: entry(k), + })), + 3, + ), + ] + const leafNodeBytes = [...sink.leaves.keys()].slice(0, 2000).map((h) => sink.nodes.get(h)!.length) + const meanLeafBytes = leafNodeBytes.reduce((a, b) => a + b, 0) / leafNodeBytes.length + console.log(`\n## ${chunking.name}, N=${N.toLocaleString()}`) + console.log( + `build: ${(buildMs / 1000).toFixed(1)} s (${((buildMs * 1000) / N).toFixed(2)} µs/entry), height ${root.level + 1}`, + ) + console.log(`nodes per level: ${perLevel.map((n, l) => `L${l}=${n}`).join(' ')}`) + console.log( + `leaf entries: mean ${(N / sizes.length).toFixed(0)}, p1 ${pct(sizes, 1)}, p10 ${pct(sizes, 10)}, p50 ${pct(sizes, 50)}, p90 ${pct(sizes, 90)}, p99 ${pct(sizes, 99)}, max ${sizes[sizes.length - 1]}; <100: ${((100 * sizes.filter((s) => s < 100).length) / sizes.length).toFixed(1)}%, >3×mean: ${((100 * sizes.filter((s) => s > 3 * (N / sizes.length)).length) / sizes.length).toFixed(1)}%; forced ${forced}`, + ) + console.log(`leaf node JSON: mean ${(meanLeafBytes / 1024).toFixed(0)} KB`) + console.log(`nodes written per commit (mean): ${results.join(' · ')}`) +} + +await run(protocolChunking, 8_192) +await run(sizeAware(1024, 64, 8_192, 1024), 8_192) diff --git a/packages/protocol/scripts/gen-vectors.ts b/packages/protocol/scripts/gen-vectors.ts new file mode 100644 index 0000000..0d06710 --- /dev/null +++ b/packages/protocol/scripts/gen-vectors.ts @@ -0,0 +1,608 @@ +/** + * Generate the protocol test vectors: test/vectors/v2.json. + * + * pnpm --filter @underlay/protocol vectors # write + * pnpm --filter @underlay/protocol vectors --check # compare, exit 1 on any difference + * + * Tree vectors are given as a recipe (how to generate the entries) plus the + * expected results, so a second implementation can regenerate the inputs without + * a multi-megabyte fixture. test/vectors.test.ts runs --check in CI. + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + boundaryBytes, + buildTree, + checkSchemaFull, + compareUtf8, + ed25519Signer, + entryHash, + type FileEntry, + fileRefs, + fileTree, + hashRecord, + hashSchema, + InputRuleError, + jcs, + makeRoot, + MAX_PATTERN_LENGTH, + MAX_RECORD_BYTES, + MAX_TYPE_BYTES, + MemorySink, + type NodeDesc, + parseRecordLine, + privateCommitment, + type PrivateSetObject, + recordCanonical, + type RecordEntry, + recordTree, + type SetObject, + sha256Hex, + signEntry, + trailingZeros, + utf8ByteLength, + versionHash, +} from '../src/index.js' + +// Non-ASCII test strings are built from code points: the formatter rewrites +// \u escapes into literal characters, which an editor could silently normalize. +const cp = (...points: number[]) => String.fromCodePoint(...points) +const EMOJI = cp(0x1f600) +const E_ACUTE = cp(0xe9) // é, one code point +const E_COMBINING = cp(0x65, 0x301) // é, e + combining acute + +const here = dirname(fileURLToPath(import.meta.url)) +const out = resolve(here, '../test/vectors/v2.json') + +// --- Canonical JSON ----------------------------------------------------------- + +const jcsCases = [ + '{"b":1,"a":2}', + '{"9":3,"10":2,"a":1}', + '{"numbers":[333333333.33333329,1E30,4.50,2e-3,0.000000000000000000000000001,-0,1e21,1e-7]}', + '{"\\u20ac":1,"\\r":2,"\\ufb33":3,"1":4,"\\ud83d\\ude00":5,"\\u0080":6,"\\u00f6":7}', + '{"s":"\\u0000\\u001f\\"\\\\\\/\\b\\f\\n\\r\\t\\u007f\\u2028é😀"}', + '[[],{},[{}],null,true,false,0,"",[1,[2,[3]]]]', + '{"e\\u0301":1,"\\u00e9":2}', +].map((input) => ({ input, output: jcs(JSON.parse(input)) })) + +// --- Input rules ---------------------------------------------------------------- + +const ruleCase = (line: string) => { + try { + const r = parseRecordLine(line) + return { line, ok: true, canonical: r.canonical, hash: sha256Hex(r.canonical) } + } catch (err) { + if (!(err instanceof InputRuleError)) throw err + return { line, ok: false, error: err.code } + } +} + +const deep = (n: number) => '['.repeat(n) + ']'.repeat(n) +const deepObject = (n: number) => '{"a":'.repeat(n - 1) + '{}' + '}'.repeat(n - 1) +const inputRuleCases = [ + '{"id":"r1","type":"Author","data":{"name":"Ada","year":1815}}', + '{"type":"Author","data":{"year":1815,"name":"Ada"},"id":"r1","private":true}', + '{"id":"r","type":"t","data":{"a":1,"a":2}}', + '{"id":"r","type":"t","data":{"a":1,"\\u0061":2}}', + '{"id":"r","type":"t","data":{"x":{"a":1},"y":{"a":1}}}', + '{"id":"r","type":"t","data":9007199254740991}', + '{"id":"r","type":"t","data":9007199254740992}', + '{"id":"r","type":"t","data":-9007199254740992}', + '{"id":"r","type":"t","data":[1e20,6.02e23,9007199254740993.0]}', + '{"id":"r","type":"t","data":"\\ud83d\\ude00"}', + '{"id":"r","type":"t","data":"\\ud83d"}', + '{"id":"r","type":"t","data":"\\ude00"}', + '{"id":"\\u00e9","type":"t","data":1}', + '{"id":"e\\u0301","type":"t","data":1}', + `{"id":"r","type":"t","data":${deep(64)}}`, + `{"id":"r","type":"t","data":${deep(65)}}`, + `{"id":"${'x'.repeat(1024)}","type":"t","data":1}`, + `{"id":"${'x'.repeat(1025)}","type":"t","data":1}`, + '{"id":"","type":"t","data":1}', + '{"id":"r","type":"a/b","data":1}', + '{"id":"r","type":"t"}', + '{"type":"t","data":1}', + '{"id":"r","data":1}', + '{"id":"r","type":"t","data":1,"private":"yes"}', + '{"id":"r","type":"t","data":1,"note":"ignored","hash":"x"}', + '{"id":"r","type":"t","data":{"9":3,"10":2}}', + // syntax + '{"id":"r","type":"t","data":}', + 'not json', + '{"id":"r","type":"t","data":"abc', + '{"id":"r","type":"t","data":{"\\x":1}}', + '{"id":"r","type":"t","data":"\\u12zz"}', + // bad_type + `{"id":"r","type":"${'t'.repeat(MAX_TYPE_BYTES)}","data":1}`, + `{"id":"r","type":"${'t'.repeat(MAX_TYPE_BYTES + 1)}","data":1}`, + `{"id":"r","type":"${E_ACUTE.repeat(MAX_TYPE_BYTES / 2 + 1)}","data":1}`, // 130 UTF-8 bytes + '{"id":"r","type":".t","data":1}', + '{"id":"r","type":"a\\\\b","data":1}', + '{"id":"r","type":"t\\u0001","data":1}', + '{"id":"r","type":"t\\u007f","data":1}', + '{"id":"r","type":"","data":1}', + '{"id":"r","type":1,"data":1}', + // too_deep at the boundary: data may nest 64 levels, the envelope being one more, + // and so may members that are otherwise ignored. + `{"id":"r","type":"t","data":${deepObject(64)}}`, + `{"id":"r","type":"t","data":${deepObject(65)}}`, + `{"id":"r","type":"t","data":1,"ignored":${deep(65)}}`, + // Code order: the first rule the text breaks, then the envelope rules in order. + '{"id":"r","type":"t","data":{"a":1,"a":2},}', + '{"id":"r","type":"t","data":{"\\x":1,"a":1,"a":2}}', + '{"id":"r","type":"t","data":{"a":"\\u12zz","a":1}}', + '{"id":"r","type":"t","data":[9007199254740992,"\\ud83d"]}', + '[1]', + '{"id":"","type":"a/b"}', + '{"id":"r","type":".t"}', + '{"id":"r","type":"t","private":1}', +].map(ruleCase) + +// Lines too long to list: the line is `prefix`, `repeat` n times, then `suffix`. +const recipeCase = (prefix: string, repeat: string, n: number, suffix: string) => { + const r = ruleCase(prefix + repeat.repeat(n) + suffix) + const verdict = 'error' in r ? { ok: false, error: r.error } : { ok: true, hash: r.hash } + return { prefix, repeat, n, suffix, ...verdict } +} +const dataPrefix = '{"id":"r","type":"t","data":"' +// The longest string data whose canonical record is exactly MAX_RECORD_BYTES. +const maxData = MAX_RECORD_BYTES - utf8ByteLength(recordCanonical('r', 't', '')) +const inputRuleRecipes = [ + recipeCase(dataPrefix, 'x', maxData, '"}'), + recipeCase(dataPrefix, 'x', maxData + 1, '"}'), + // Size is the canonical form's: whitespace in the line doesn't count… + recipeCase('{ "id" : "r" , "type" : "t" , "data" : "', 'x', maxData, '" }'), + // …and a character counts as its UTF-8 bytes. + recipeCase(dataPrefix, E_ACUTE, Math.ceil((maxData + 1) / 2), '"}'), +] + +// --- Schema rules --------------------------------------------------------------- + +const LONG = 'a'.repeat(MAX_PATTERN_LENGTH + 1) +// `ok` is the verdict of checkSchemaFull, which must be the one the case intends. +const schemaCase = (note: string, schema: unknown, ok: boolean, slug = 'T') => { + const error = checkSchemaFull(slug, schema) + if ((error === null) !== ok) throw new Error(`schemaRules "${note}": ${error ?? 'accepted'}`) + return { note, slug, schema, ok } +} +const schemaRules = [ + // Section 5 + schemaCase('an ordinary schema', { type: 'object', properties: { a: { type: 'string' } } }, true), + schemaCase('a private type', { private: true, type: 'object' }, true), + schemaCase('root private false', { private: false }, true), + schemaCase('root private not a boolean', { private: 'true' }, false), + schemaCase('root private null', { private: null }, false), + schemaCase('a property called "private"', { properties: { private: { type: 'boolean' } } }, true), + schemaCase('field-level private', { properties: { ssn: { private: true } } }, false), + schemaCase( + 'field-level private, nested in a definition', + { definitions: { a: { properties: { b: { properties: { c: { private: true } } } } } } }, + false, + ), + schemaCase( + 'field-level private under a definition called "enum"', + { definitions: { enum: { properties: { s: { private: true } } } } }, + false, + ), + schemaCase('private on items, not a property', { items: { private: true } }, true), + schemaCase( + 'private on a definition, not a property', + { definitions: { a: { private: true } } }, + true, + ), + schemaCase( + 'private inside default, which is data', + { properties: { a: { default: { properties: { b: { private: true } } } } } }, + true, + ), + schemaCase('pattern of 256', { pattern: 'a'.repeat(MAX_PATTERN_LENGTH) }, true), + schemaCase('pattern of 257', { pattern: LONG }, false), + schemaCase( + 'pattern of 129 astral characters (258 UTF-16 code units)', + { pattern: EMOJI.repeat(129) }, + false, + ), + schemaCase('patternProperties key of 257', { patternProperties: { [LONG]: {} } }, false), + schemaCase( + 'pattern of 257 under a property called "type"', + { properties: { type: { type: 'string', pattern: LONG } } }, + false, + ), + schemaCase('pattern of 257 inside enum, which is data', { enum: [{ pattern: LONG }] }, true), + schemaCase('pattern of 257 inside const, which is data', { const: { pattern: LONG } }, true), + schemaCase('pattern of 257 inside default, which is data', { default: { pattern: LONG } }, true), + schemaCase( + 'pattern of 257 inside examples, which is data', + { examples: [{ pattern: LONG }] }, + true, + ), + schemaCase('slug with a slash', {}, false, 'a/b'), + schemaCase('slug starting with "."', {}, false, '.t'), + schemaCase('empty slug', {}, false, ''), + schemaCase('slug over 128 bytes', {}, false, 't'.repeat(MAX_TYPE_BYTES + 1)), + // Section 5.1 + schemaCase('not an object', true, false), + schemaCase('$schema draft-07', { $schema: 'http://json-schema.org/draft-07/schema#' }, true), + schemaCase( + '$schema draft-07 without "#"', + { $schema: 'http://json-schema.org/draft-07/schema' }, + true, + ), + schemaCase('$schema 2020-12', { $schema: 'https://json-schema.org/draft/2020-12/schema' }, false), + schemaCase('$schema draft-04', { $schema: 'http://json-schema.org/draft-04/schema#' }, false), + schemaCase('invalid against the meta-schema: type', { type: 'text' }, false), + schemaCase('invalid against the meta-schema: required', { required: 'a' }, false), + schemaCase('a pattern that needs the u flag', { pattern: '^\\p{L}+$' }, true), + schemaCase('an invalid pattern', { pattern: '(' }, false), + schemaCase('a pattern invalid only with the u flag', { pattern: '\\a' }, false), + schemaCase( + 'a patternProperties key invalid only with the u flag', + { patternProperties: { '\\a': {} } }, + false, + ), + schemaCase( + '$ref to a definition', + { definitions: { a: { type: 'string' } }, properties: { x: { $ref: '#/definitions/a' } } }, + true, + ), + schemaCase( + '$ref against the base URI', + { + definitions: { a: { type: 'string' } }, + properties: { x: { $ref: 'https://schema.underlay.invalid/#/definitions/a' } }, + }, + true, + ), + schemaCase( + '$ref to an $id', + { + definitions: { a: { $id: 'a.json', type: 'string' } }, + properties: { x: { $ref: 'a.json' } }, + }, + true, + ), + schemaCase( + '$ref to the draft-07 meta-schema', + { $ref: 'http://json-schema.org/draft-07/schema#' }, + true, + ), + schemaCase('unresolvable $ref', { $ref: '#/definitions/missing' }, false), + schemaCase( + 'unresolvable $ref to another document', + { properties: { x: { $ref: 'other.json' } } }, + false, + ), +] + +// --- Hashes --------------------------------------------------------------------- + +const recordHashes = [ + { id: 'r1', type: 'Author', data: { name: 'Ada', year: 1815 } }, + { + id: 'r2', + type: 'Pub', + data: { title: 'On Computable Numbers', refs: [{ $file: `sha256:${'a'.repeat(64)}` }] }, + }, + { id: '9', type: 'T', data: { 10: 'x', 9: 'y', z: [1.5, null, true] } }, + { id: '😀', type: 'Émoji', data: 'plain string data' }, +].map((r) => ({ ...r, ...hashRecord(r.id, r.type, r.data) })) + +const schemaHashes = [ + { type: 'object', properties: { name: { type: 'string' } } }, + { type: 'object', properties: { 1: { type: 'string' }, 10: { type: 'integer' } }, private: true }, +].map((schema) => ({ schema, canonical: jcs(schema), hash: hashSchema(schema) })) + +const boundaryCases = ['', 'a', 'r0', 'k12345', EMOJI, E_COMBINING].map((key) => { + const u = boundaryBytes(key) + return { key, u: Buffer.from(u).toString('hex'), trailingZeros: trailingZeros(u) } +}) +// Keys whose boundary hash ends in ≥10 and ≥16 zero bits, found by search. +for (const want of [10, 16]) { + for (let i = 0; ; i++) { + const key = `b${i}` + const u = boundaryBytes(key) + if (trailingZeros(u) >= want) { + boundaryCases.push({ + key, + u: Buffer.from(u).toString('hex'), + trailingZeros: trailingZeros(u), + }) + break + } + } +} + +const keyOrder = [ + 'a', + 'b', + 'aa', + '', + 'Z', + cp(0x7f), + cp(0x80), + cp(0xffff), + EMOJI, + cp(0xe000), + E_ACUTE, + E_COMBINING, +] + .slice() + .sort(compareUtf8) + +// --- Trees ---------------------------------------------------------------------- + +interface TreeVector { + name: string + recipe: string + entries: number + root: string | null + count: number + bytes: number + height: number + nodesPerLevel: number[] + leafSizes?: number[] +} + +function recordEntries(keys: string[]): RecordEntry[] { + return keys + .slice() + .sort(compareUtf8) + .map((key) => { + const { hash, canonical } = hashRecord(key, 'T', { k: key }) + return { key, hash, size: Buffer.byteLength(canonical) } + }) +} + +function treeVector( + name: string, + recipe: string, + entries: RecordEntry[], + withLeafSizes = false, +): TreeVector { + const levels: number[] = [] + const leafSizes: number[] = [] + const sink = { + leaf(_d: NodeDesc, _j: string, es: readonly RecordEntry[]) { + levels[0] = (levels[0] ?? 0) + 1 + leafSizes.push(es.length) + }, + interior(d: NodeDesc) { + levels[d.level] = (levels[d.level] ?? 0) + 1 + }, + } + const root = buildTree(recordTree, sink, entries) + const v: TreeVector = { + name, + recipe, + entries: entries.length, + root: root?.hash ?? null, + count: root?.count ?? 0, + bytes: root?.bytes ?? 0, + height: root ? root.level + 1 : 0, + nodesPerLevel: levels.slice(0, root ? root.level + 1 : 0), + } + if (withLeafSizes) v.leafSizes = leafSizes + return v +} + +const RECIPE = + 'entries: for each key, hashRecord(key, "T", {"k": key}) → [key, hash, UTF-8 length of the canonical record]; sorted by key' +const range = (n: number, f: (i: number) => T): T[] => Array.from({ length: n }, (_, i) => f(i)) + +const trees: TreeVector[] = [ + treeVector('empty', `${RECIPE}; no keys`, []), + treeVector('one', `${RECIPE}; keys ["only"]`, recordEntries(['only'])), + treeVector( + 'small', + `${RECIPE}; keys "r0".."r999"`, + recordEntries(range(1000, (i) => `r${i}`)), + true, + ), + treeVector( + 'medium', + `${RECIPE}; keys "r0".."r99999"`, + recordEntries(range(100_000, (i) => `r${i}`)), + ), + treeVector( + 'unicode', + `${RECIPE}; keys "é", "e\\u0301", "😀", "\\uffff" for i in 0..2499`, + recordEntries( + range(2500, (i) => [ + `${E_ACUTE}${i}`, + `${E_COMBINING}${i}`, + `${EMOJI}${i}`, + `${cp(0xffff)}${i}`, + ]).flat(), + ), + true, + ), + treeVector( + 'forced-leaf-splits', + `${RECIPE}; keys "f" for i in 0..19999 whose boundary hash has fewer than 10 trailing zero bits (no natural leaf boundary at all)`, + recordEntries( + range(20_000, (i) => `f${i}`).filter((k) => trailingZeros(boundaryBytes(k)) < 10), + ), + true, + ), +] + +// File trees: key = file hash, value = size. +const fileEntries: FileEntry[] = range(3000, (i) => ({ + key: sha256Hex(`file${i}`), + size: i * 1000 + 1, +})).sort((a, b) => compareUtf8(a.key, b.key)) +const fileRoot = buildTree(fileTree, new MemorySink(), fileEntries) + +// One leaf, spelled out, so the node encoding can be checked by eye. +const tinyLeafSink = new MemorySink() +const tinyLeaf = buildTree(recordTree, tinyLeafSink, recordEntries(['a', 'b', 'c']))! +const tinyInteriorSink = new MemorySink() +const tinyInteriorRoot = buildTree( + recordTree, + tinyInteriorSink, + recordEntries(range(3000, (i) => `r${i}`)), +)! + +// --- Roots ---------------------------------------------------------------------- + +const smallTree = trees.find((t) => t.name === 'small')! +const publicSet: SetObject = { + types: { + Author: { + schema: schemaHashes[0]!.hash, + root: smallTree.root, + count: smallTree.count, + bytes: smallTree.bytes, + }, + Empty: { schema: schemaHashes[0]!.hash, root: null, count: 0, bytes: 0 }, + }, + files: { root: fileRoot!.hash, count: fileRoot!.count, bytes: fileRoot!.bytes }, +} +const privateSet: PrivateSetObject = { + types: { + Secret: { + schema: schemaHashes[1]!.hash, + root: tinyLeaf.hash, + count: tinyLeaf.count, + bytes: tinyLeaf.bytes, + }, + }, + files: { root: null, count: 0, bytes: 0 }, + salt: sha256Hex('vector salt'), +} +const metadata = { name: 'Vector collection', readme: '# Hi\n', tags: ['a', 'b'], 10: 'x', 9: 'y' } +const rootPublicOnly = makeRoot(metadata, publicSet, null) +const rootWithPrivate = makeRoot(metadata, publicSet, privateSet) +const roots = [ + { + name: 'public-only', + root: rootPublicOnly, + canonical: jcs(rootPublicOnly), + versionHash: versionHash(rootPublicOnly), + }, + { + name: 'with-private', + privateSet, + privateCanonical: jcs(privateSet), + commitment: privateCommitment(privateSet), + root: rootWithPrivate, + canonical: jcs(rootWithPrivate), + versionHash: versionHash(rootWithPrivate), + }, +] + +// --- File references -------------------------------------------------------------- + +const H = (c: string) => c.repeat(64) +const fileRefCases = [ + { data: { f: { $file: `sha256:${H('a')}` } } }, + { + data: { + list: [ + { $file: `sha256:${H('b')}` }, + { nested: { $file: `sha256:${H('c')}`, extra: { $file: `sha256:${H('d')}` } } }, + ], + }, + }, + { + data: { + f: { $file: `sha256:${H('A')}` }, + g: { $file: H('e') }, + h: { $file: 1, inner: { $file: `sha256:${H('f')}` } }, + }, + }, + { data: [{ $file: `sha256:${H('a')}` }, { $file: `sha256:${H('a')}` }] }, +].map((c) => ({ ...c, refs: fileRefs(c.data) })) + +// --- Version log ------------------------------------------------------------------ + +// Ed25519 signatures are deterministic (RFC 8032), so a fixed seed fixes every byte. +const logSeed = Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)).toString('base64url') +const logSigner = await ed25519Signer(logSeed) +const logUnsigned = { + collectionId: '0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b', + seq: 2, + semver: 'v1.1.0', + versionHash: roots[0]!.versionHash, + baseSemver: 'v1.0.0', + message: 'Second version', + appId: null, + actorId: null, + createdAt: '2026-10-03T12:00:00.000Z', + prev: sha256Hex('entry 1'), +} +const logSigned = await signEntry(logSigner, logUnsigned) +const { sig: _sig, ...logToSign } = logSigned +const logEntry = { + privateKeySeed: logSeed, + publicKey: logSigner.publicKey, + unsigned: logUnsigned, + signedBytes: jcs(logToSign), + entry: logSigned, + canonical: jcs(logSigned), + entryHash: entryHash(logSigned), + head: jcs({ + entryHash: entryHash(logSigned), + seq: logSigned.seq, + versionHash: logSigned.versionHash, + }), +} + +const vectors = { + protocolVersion: 2, + generatedBy: 'packages/protocol/scripts/gen-vectors.ts', + constants: await import('../src/constants.js').then((m) => ({ ...m })), + jcs: jcsCases, + inputRules: inputRuleCases, + inputRuleRecipes, + schemaRules, + recordHashes, + schemaHashes, + boundary: boundaryCases, + keyOrder, + nodes: { + leaf: { + keys: ['a', 'b', 'c'], + recipe: RECIPE, + hash: tinyLeaf.hash, + json: tinyLeafSink.nodes.get(tinyLeaf.hash), + }, + interior: { + recipe: `${RECIPE}; keys "r0".."r2999"; the root node`, + hash: tinyInteriorRoot.hash, + json: tinyInteriorSink.nodes.get(tinyInteriorRoot.hash), + }, + }, + trees, + fileTree: { + recipe: + 'entries: key = sha256Hex("file"), size = i × 1000 + 1, for i in 0..2999; sorted by key', + root: fileRoot!.hash, + count: fileRoot!.count, + bytes: fileRoot!.bytes, + }, + roots, + fileRefs: fileRefCases, + logEntry, +} + +const text = JSON.stringify(vectors, null, 1) + '\n' +if (process.argv.includes('--check')) { + const existing = readFileSync(out, 'utf8') + // Compare content, not layout: the formatter (and the pre-commit hook) may + // re-wrap the file. + const same = JSON.stringify(JSON.parse(existing)) === JSON.stringify(JSON.parse(text)) + if (!same) { + console.error( + 'test/vectors/v2.json is out of date with the implementation. If the change is intended, it is a protocol change: regenerate with `pnpm vectors` and say so in docs/protocol-v2.md.', + ) + process.exit(1) + } + console.log('vectors match') +} else { + writeFileSync(out, text) + console.log(`wrote ${out}`) +} diff --git a/packages/protocol/scripts/validator-suite.ts b/packages/protocol/scripts/validator-suite.ts new file mode 100644 index 0000000..70e731b --- /dev/null +++ b/packages/protocol/scripts/validator-suite.ts @@ -0,0 +1,278 @@ +/** + * Validator evidence (edge-redesign-build.md, "Validator choice"): + * + * 1. the official JSON Schema Test Suite, draft-07, required and optional/format, + * against @cfworker/json-schema as shipped, @hyperjump/json-schema, and our + * wrapper (src/validate.ts), which implements v1's AJV dialect and so fails + * some draft-07 tests on purpose; + * 2. throughput over the production records cached by diff-validators.ts. + * + * npx tsx packages/protocol/scripts/validator-suite.ts --suite [--data ] + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' + +import { Validator } from '@cfworker/json-schema' +import { Ajv } from 'ajv' +import addFormatsModule from 'ajv-formats' + +import { compileSchema } from '../src/validate.js' + +const args = process.argv.slice(2) +const option = (name: string, fallback?: string) => { + const i = args.indexOf(name) + return i >= 0 && args[i + 1] ? args[i + 1]! : fallback +} +const SUITE = option('--suite') +const DATA = option('--data', '/Users/travis/.claude-kf/jobs/71495a02/tmp/validator-data')! + +type Check = (data: unknown) => boolean +type Impl = { name: string; compile(schema: unknown): Promise } + +const remotes: [string, unknown][] = [] + +function walk(dir: string, out: string[] = []): string[] { + for (const f of readdirSync(dir)) { + const p = join(dir, f) + if (statSync(p).isDirectory()) walk(p, out) + else if (f.endsWith('.json')) out.push(p) + } + return out +} + +// --- Implementations -------------------------------------------------------------- + +const cfworker: Impl = { + name: '@cfworker/json-schema', + async compile(schema) { + const v = new Validator(schema as never, '7', true) + for (const [uri, s] of remotes) v.addSchema(s as never, uri) + return (data) => v.validate(data).valid + }, +} + +let hjCounter = 0 +let hj: typeof import('@hyperjump/json-schema/draft-07') | null = null +const hyperjump: Impl = { + name: '@hyperjump/json-schema', + async compile(schema) { + if (!hj) { + hj = await import('@hyperjump/json-schema/draft-07') + const formats = '@hyperjump/json-schema/formats' + await import(formats) + hj.setShouldValidateFormat(true) + for (const [uri, s] of remotes) { + try { + hj.registerSchema( + structuredClone(s) as never, + uri, + 'http://json-schema.org/draft-07/schema', + ) + } catch { + // Remotes for other drafts. + } + } + } + const uri = `https://suite.invalid/schema/${hjCounter++}` + hj.registerSchema( + structuredClone(schema) as never, + uri, + 'http://json-schema.org/draft-07/schema', + ) + const v = await hj.validate(uri) + return (data) => v(data as never).valid + }, +} + +const ours: Impl = { + name: 'v2 wrapper (AJV dialect)', + async compile(schema) { + const v = compileSchema(schema) + return (data) => v(data).length === 0 + }, +} + +const addFormats = addFormatsModule as unknown as (ajv: Ajv) => Ajv +const ajv: Impl = { + name: 'ajv (v1, Node only)', + async compile(schema) { + const a = new Ajv({ allErrors: true, strict: false }) + addFormats(a) + const v = a.compile(schema as never) + return (data) => v(data) as boolean + }, +} + +// --- 1. Test suite ---------------------------------------------------------------- + +interface Case { + description: string + schema: unknown + tests: { description: string; data: unknown; valid: boolean }[] +} + +async function runSuite(impl: Impl, files: string[], root: string) { + let pass = 0 + let fail = 0 + let error = 0 + const failures: string[] = [] + for (const file of files) { + const cases = JSON.parse(readFileSync(file, 'utf8')) as Case[] + for (const c of cases) { + let check: Check + try { + check = await impl.compile(c.schema) + } catch (err) { + error += c.tests.length + failures.push( + `${relative(root, file)}: ${c.description}: compile: ${(err as Error).message.slice(0, 100)}`, + ) + continue + } + for (const t of c.tests) { + let got: boolean + try { + got = check(t.data) + } catch (err) { + error++ + failures.push( + `${relative(root, file)}: ${c.description} / ${t.description}: threw ${(err as Error).message.slice(0, 80)}`, + ) + continue + } + if (got === t.valid) pass++ + else { + fail++ + failures.push(`${relative(root, file)}: ${c.description} / ${t.description}`) + } + } + } + } + return { pass, fail, error, failures } +} + +if (SUITE) { + for (const f of walk(join(SUITE, 'remotes'))) { + remotes.push([ + `http://localhost:1234/${relative(join(SUITE, 'remotes'), f)}`, + JSON.parse(readFileSync(f, 'utf8')), + ]) + } + const dir = join(SUITE, 'tests', 'draft7') + const required = readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => join(dir, f)) + const formats = walk(join(dir, 'optional', 'format')) + const otherOptional = readdirSync(join(dir, 'optional')) + .filter((f) => f.endsWith('.json')) + .map((f) => join(dir, 'optional', f)) + for (const [label, files] of [ + ['draft7 required', required], + ['draft7 optional/format', formats], + ['draft7 optional (other)', otherOptional], + ] as const) { + console.log(`\n== ${label} (${files.length} files)`) + for (const impl of [cfworker, hyperjump, ours, ajv]) { + const r = await runSuite(impl, files, dir) + const total = r.pass + r.fail + r.error + console.log( + `${impl.name.padEnd(28)} ${r.pass}/${total} pass, ${r.fail} fail, ${r.error} error`, + ) + if (process.env.VERBOSE || label === 'draft7 required') { + for (const f of r.failures.slice(0, 40)) console.log(` ${f}`) + if (r.failures.length > 40) console.log(` … ${r.failures.length - 40} more`) + } + } + } +} + +// --- 2. Throughput ---------------------------------------------------------------- + +interface Workload { + name: string + schemas: Record + records: { type: string; data: unknown }[] +} + +function loadWorkload(): Workload[] { + const out: Workload[] = [] + for (const owner of readdirSync(DATA)) { + const od = join(DATA, owner) + if (!statSync(od).isDirectory()) continue + for (const slug of readdirSync(od)) { + const cd = join(od, slug) + const recs = readdirSync(cd).filter((f) => f.startsWith('records-')) + if (recs.length === 0) continue + const semver = recs[0]!.slice('records-'.length, -'.ndjson'.length) + const version = JSON.parse(readFileSync(join(cd, 'versions', `${semver}.json`), 'utf8')) as { + schemas: Record | { slug: string; schema: unknown }[] + } + const schemas: Record = Array.isArray(version.schemas) + ? Object.fromEntries(version.schemas.map((s) => [s.slug, s.schema])) + : version.schemas + const records = readFileSync(join(cd, recs[0]!), 'utf8') + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l) as { type: string; data: unknown }) + out.push({ name: `${owner}/${slug}`, schemas, records }) + } + } + return out +} + +async function bench(impl: Impl, work: Workload[]) { + let compileMs = 0 + let validateMs = 0 + let n = 0 + let valid = 0 + const per: { name: string; ms: number; n: number }[] = [] + for (const w of work) { + const t0 = performance.now() + const checks = new Map() + for (const [slug, s] of Object.entries(w.schemas)) { + try { + checks.set(slug, await impl.compile(s)) + } catch { + // Counted by diff-validators.ts; skip here. + } + } + const t1 = performance.now() + for (const r of w.records) { + const check = checks.get(r.type) + if (!check) continue + if (check(r.data)) valid++ + n++ + } + const t2 = performance.now() + compileMs += t1 - t0 + validateMs += t2 - t1 + per.push({ name: w.name, ms: t2 - t1, n: w.records.length }) + } + return { compileMs, validateMs, n, valid, per } +} + +if (!args.includes('--no-bench')) { + const work = loadWorkload() + const total = work.reduce((s, w) => s + w.records.length, 0) + console.log( + `\n== throughput: ${work.length} collections, ${total} production records (Node ${process.version})`, + ) + for (const impl of [cfworker, hyperjump, ours, ajv]) { + // Best of three full passes (the first warms the JIT). + let r = await bench(impl, work) + for (let i = 0; i < 2; i++) { + const again = await bench(impl, work) + if (again.validateMs < r.validateMs) r = again + } + console.log( + `${impl.name.padEnd(28)} ${Math.round(r.n / (r.validateMs / 1000)).toLocaleString()} records/s (${(r.validateMs / 1000).toFixed(2)} s for ${r.n}, ${r.valid} valid), compile ${r.compileMs.toFixed(0)} ms`, + ) + // One recursive collection dominates some totals; show the rest without it. + const slowest = r.per.reduce((a, b) => (b.ms > a.ms ? b : a)) + const restMs = r.validateMs - slowest.ms + const restN = r.n - slowest.n + console.log( + `${''.padEnd(28)} slowest: ${slowest.name} ${slowest.ms.toFixed(0)} ms for ${slowest.n}; the rest ${Math.round(restN / (restMs / 1000)).toLocaleString()} records/s`, + ) + } +} diff --git a/packages/protocol/scripts/workerd-bench/.gitignore b/packages/protocol/scripts/workerd-bench/.gitignore new file mode 100644 index 0000000..fe80ec2 --- /dev/null +++ b/packages/protocol/scripts/workerd-bench/.gitignore @@ -0,0 +1,2 @@ +data.json +.wrangler/ diff --git a/packages/protocol/scripts/workerd-bench/worker.ts b/packages/protocol/scripts/workerd-bench/worker.ts new file mode 100644 index 0000000..18b0a7e --- /dev/null +++ b/packages/protocol/scripts/workerd-bench/worker.ts @@ -0,0 +1,90 @@ +/** + * Validator throughput inside workerd (the Workers runtime), for the validator + * choice in edge-redesign-build.md. data.json is a sample of the production + * records cached by diff-validators.ts (not committed; see validator-suite.ts). + * + * cd packages/protocol/scripts/workerd-bench && npx wrangler dev --local --port 4390 + * curl localhost:4390 + * + * Workers only advance the clock across I/O, so each timed section ends with a + * timer await before reading Date.now(). + */ +import { Validator } from '@cfworker/json-schema' +import { registerSchema, setShouldValidateFormat, validate } from '@hyperjump/json-schema/draft-07' +import '@hyperjump/json-schema/formats' + +import { compileSchema } from '../../src/validate.js' +import data from './data.json' + +type Check = (d: unknown) => boolean +type Work = { + name: string + schemas: Record + records: { type: string; data: unknown }[] +} + +const tick = () => new Promise((r) => setTimeout(r, 0)) +let n = 0 + +const impls: Record Promise> = { + cfworker: async (s) => { + const v = new Validator(s as never, '7', true) + return (d) => v.validate(d).valid + }, + hyperjump: async (s) => { + const uri = `https://bench.invalid/${n++}` + registerSchema(structuredClone(s) as never, uri, 'http://json-schema.org/draft-07/schema') + const v = await validate(uri) + return (d) => v(d as never).valid + }, + v2wrapper: async (s) => { + const v = compileSchema(s) + return (d) => v(d).length === 0 + }, +} + +async function run(compile: (s: unknown) => Promise) { + const work = data as unknown as Work[] + const checks: Map[] = [] + for (const w of work) { + const m = new Map() + for (const [slug, s] of Object.entries(w.schemas)) { + try { + m.set(slug, await compile(s)) + } catch { + // Counted elsewhere. + } + } + checks.push(m) + } + let best = Infinity + let count = 0 + let valid = 0 + for (let round = 0; round < 3; round++) { + await tick() + const t0 = Date.now() + count = 0 + valid = 0 + work.forEach((w, i) => { + for (const r of w.records) { + const c = checks[i]!.get(r.type) + if (!c) continue + if (c(r.data)) valid++ + count++ + } + }) + await tick() + best = Math.min(best, Date.now() - t0) + } + return { ms: best, count, valid, perSec: Math.round(count / (best / 1000)) } +} + +setShouldValidateFormat(true) + +export default { + async fetch(): Promise { + const out: Record = {} + for (const [name, compile] of Object.entries(impls)) out[name] = await run(compile) + return Response.json(out) + }, +} diff --git a/packages/protocol/scripts/workerd-bench/wrangler.jsonc b/packages/protocol/scripts/workerd-bench/wrangler.jsonc new file mode 100644 index 0000000..0ffa9a6 --- /dev/null +++ b/packages/protocol/scripts/workerd-bench/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "name": "validator-bench", + "main": "worker.ts", + "compatibility_date": "2025-09-01", + "compatibility_flags": ["nodejs_compat"], + "limits": { "cpu_ms": 300000 }, +} diff --git a/packages/protocol/src/constants.ts b/packages/protocol/src/constants.ts new file mode 100644 index 0000000..2ed8d62 --- /dev/null +++ b/packages/protocol/src/constants.ts @@ -0,0 +1,51 @@ +/** + * Protocol constants for Underlay protocol v2. + * + * Everything here is protocol: a second implementation must use the same values + * or it will build different trees, accept different records, and compute + * different version hashes. See docs/protocol-v2.md. + * + * Status: stable. Frozen 2026-10-03 with the values the tree-parameter + * experiments chose (edge-redesign-build.md, "Tree parameter experiments"). + * No value here may change without a new PROTOCOL_VERSION. + */ + +/** + * The protocol version this package writes: the `underlay` field of every + * version root. The package's own semver is independent of it. + */ +export const PROTOCOL_VERSION = 2 + +/** Prefix of a v2 version hash string: `ulv2:<64 hex>`. */ +export const VERSION_HASH_PREFIX = 'ulv2:' + +// --- Tree shape ------------------------------------------------------------- + +/** A leaf ends after a key whose boundary hash has at least this many trailing zero bits (mean 1,024 entries). */ +export const LEAF_BOUNDARY_BITS = 10 +/** Forced leaf split: a leaf never holds more entries than this. */ +export const LEAF_MAX_ENTRIES = 8_192 +/** Level i ≥ 1 needs LEAF_BOUNDARY_BITS + i × this many trailing zero bits (mean fanout 64). */ +export const INTERIOR_BOUNDARY_BITS_STEP = 6 +/** Forced interior split: an interior node never holds more children than this. */ +export const INTERIOR_MAX_CHILDREN = 1_024 + +// --- Input rules ------------------------------------------------------------ + +/** Largest integer literal magnitude accepted (2^53 − 1). */ +export const MAX_SAFE_INTEGER_LITERAL = '9007199254740991' +/** Maximum nesting depth of a record's JSON (the envelope object is depth 1). */ +export const MAX_JSON_DEPTH = 64 +/** Maximum canonical record size in bytes: `{"id":…,"type":…,"data":…}` as UTF-8. */ +export const MAX_RECORD_BYTES = 8 * 1024 * 1024 +/** Maximum record id length in UTF-8 bytes. Bounds leaf node size. */ +export const MAX_ID_BYTES = 1_024 +/** Maximum type slug length in UTF-8 bytes. */ +export const MAX_TYPE_BYTES = 128 +/** Maximum canonical schema size in bytes. */ +export const MAX_SCHEMA_BYTES = 256 * 1024 +/** + * Longest `pattern` value or `patternProperties` key in a schema, in UTF-16 code + * units (JavaScript string length): long patterns are the main ReDoS vector. + */ +export const MAX_PATTERN_LENGTH = 256 diff --git a/packages/protocol/src/file-refs.ts b/packages/protocol/src/file-refs.ts new file mode 100644 index 0000000..631305b --- /dev/null +++ b/packages/protocol/src/file-refs.ts @@ -0,0 +1,31 @@ +/** + * File references in record data. + * + * One rule (v1 had two): a reference is any object, at any depth, whose `$file` + * is a string `sha256:<64 lowercase hex>`. The walk doesn't descend into a + * reference object. Returns bare hex hashes, deduplicated, in first-seen order. + */ +const FILE_REF = /^sha256:([0-9a-f]{64})$/ + +export function fileRefs(data: unknown): string[] { + const out = new Set() + const walk = (v: unknown) => { + if (v === null || typeof v !== 'object') return + if (Array.isArray(v)) { + for (const x of v) walk(x) + return + } + const o = v as Record + const ref = o.$file + if (typeof ref === 'string') { + const m = FILE_REF.exec(ref) + if (m) { + out.add(m[1]!) + return + } + } + for (const k in o) walk(o[k]) + } + walk(data) + return [...out] +} diff --git a/packages/protocol/src/format.ts b/packages/protocol/src/format.ts new file mode 100644 index 0000000..ffaa263 --- /dev/null +++ b/packages/protocol/src/format.ts @@ -0,0 +1,75 @@ +export * from './constants.js' +export { jcs } from './jcs.js' +export { compareUtf8, isWellFormed, utf8, utf8ByteLength } from './utf8.js' +export { hashRecord, hashSchema, recordCanonical, sha256Hex } from './hash.js' +export { nativeSha256, sha256, sha256Hasher } from './sha256.js' +export { + checkRecordId, + checkTypeSlug, + InputRuleError, + type InputRuleCode, + parseRecordLine, + parseStrict, + type RecordInput, + scanJson, +} from './input-rules.js' +export { fileRefs } from './file-refs.js' +export { + checkSchema, + checkSchemaBounds, + checkSchemaFull, + compileSchema, + type ExtraFieldWarning, + findExtraFields, + SchemaError, + type SchemaValidator, + stripToSchema, +} from './validate.js' +export * from './root.js' +export * from './tree/node.js' +export { + boundaryBytes, + type Chunking, + fixedChunking, + type FixedChunkingParams, + protocolChunking, + trailingZeros, +} from './tree/chunking.js' +export { + type BuilderOptions, + buildTree, + MemorySink, + TreeBuilder, + type TreeSink, +} from './tree/builder.js' +export { MapSource, type NodeSource, resolveRoot, rootDesc } from './tree/source.js' +export { + type Change, + inRange, + type KeyRange, + mergeTree, + type MergeOptions, + type MergeResult, + type MergeStats, +} from './tree/merge.js' +export { + type DiffEntry, + diffTrees, + entryAt, + getEntry, + iterate, + type IterateOptions, + leaves, + newNodes, + rankOf, +} from './tree/read.js' +export { verifyTree, type VerifyResult } from './tree/verify.js' +export { assembleTree, type Segment } from './tree/assemble.js' +export { + type BumpType, + bumpType, + compareSemver, + deriveSemver, + parseSemver, + type SemverComponents, +} from './semver.js' diff --git a/packages/protocol/src/hash.ts b/packages/protocol/src/hash.ts new file mode 100644 index 0000000..cf81ce0 --- /dev/null +++ b/packages/protocol/src/hash.ts @@ -0,0 +1,29 @@ +import { jcs } from './jcs.js' +import { sha256Hex } from './sha256.js' + +export { sha256Hex } + +/** + * The canonical record: a fixed `{"id","type","data"}` envelope with only `data` + * canonicalized by JCS. The envelope keeps today's field order so that records + * without integer-like keys keep their v1 hashes; JCS over the whole record would + * sort it to `data, id, type` and change every hash. + */ +export function recordCanonical(id: string, type: string, data: unknown): string { + return ( + '{"id":' + JSON.stringify(id) + ',"type":' + JSON.stringify(type) + ',"data":' + jcs(data) + '}' + ) +} + +export function hashRecord( + id: string, + type: string, + data: unknown, +): { hash: string; canonical: string } { + const canonical = recordCanonical(id, type, data) + return { hash: sha256Hex(canonical), canonical } +} + +export function hashSchema(schema: unknown): string { + return sha256Hex(jcs(schema)) +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 0000000..899bc0e --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,65 @@ +/** + * @underlay/protocol: Underlay data lives in a bucket; this package reads and + * writes it. The format (JCS, hashing, input rules, validation, trees, roots), + * the repository layout and signed version log, and the stores. + */ +export * from './format.js' +export * from './repo/types.js' +export * from './repo/repo.js' +export { + fsck, + type FsckCursor, + type FsckOptions, + type FsckReport, + fsckStep, + type FsckStepOptions, +} from './repo/fsck.js' +export * from './repo/log.js' +export { gunzip, gunzipMembers, gunzipText, gzip, isGzip, splitLines } from './repo/gzip.js' +export { Lru } from './repo/lru.js' +export { + type BuildInput, + type BuildResult, + type BuildTypeInput, + buildVersion, + type ChangeSource, + isPrivateSchema, +} from './repo/build.js' +export { + applyFileSet, + declaredFiles, + referenceCounts, + FileRefDelta, + type FileSetResult, + type FileSizes, + MissingFilesError, + rebuildFileRefs, + type SetName, + tracksTypes, + typeFileRefs, +} from './repo/file-sets.js' +export { + type PackObject, + type PackOptions, + packVersion, + type ReceiveOptions, + type ReceiveResult, + receiveVersion, + type SyncSets, + type SyncTree, + treeObjects, + type VersionWork, + versionWork, +} from './repo/sync.js' +export { type TarEntry, tarChunks, type TarFile, tarStream, untar } from './repo/tar.js' +export { blobObject, MemoryStore, memoryStore } from './stores/memory.js' +export { + type LifecycleRule, + type S3Config, + s3CopyLimits, + S3Error, + S3Store, + s3Store, +} from './stores/s3.js' +export { FileStore, fileStore, type FileStoreOptions, serveSignedBlob } from './stores/fs.js' +export { type R2BucketLike, r2Store } from './stores/r2.js' diff --git a/packages/protocol/src/input-rules.ts b/packages/protocol/src/input-rules.ts new file mode 100644 index 0000000..8ffa7c5 --- /dev/null +++ b/packages/protocol/src/input-rules.ts @@ -0,0 +1,301 @@ +/** + * Input rules (protocol v2, "Input rules"). + * + * `JSON.parse` silently changes some inputs: it rounds large integers, keeps only + * the last of duplicate keys, and accepts lone UTF-16 surrogates. A record changed + * that way is not what the client sent, and an implementation in another language + * would hash something different. So v2 rejects such input, and it has to look at + * the source text to do it: after parsing, `1e20` and `100000000000000000000` are + * the same double and duplicate keys are gone. + * + * `scanJson` is a single pass over the text that checks the rules without building + * values. `JSON.parse` still builds the value; the scan only rejects. Both the CLI + * and the server run the same scan, so they accept exactly the same inputs. + */ +import { + MAX_ID_BYTES, + MAX_JSON_DEPTH, + MAX_RECORD_BYTES, + MAX_SAFE_INTEGER_LITERAL, + MAX_TYPE_BYTES, +} from './constants.js' +import { recordCanonical } from './hash.js' +import { utf8ByteLength } from './utf8.js' + +export type InputRuleCode = + | 'syntax' + | 'duplicate_key' + | 'unsafe_integer' + | 'lone_surrogate' + | 'too_deep' + | 'record_too_large' + | 'bad_id' + | 'bad_type' + | 'bad_envelope' + +export class InputRuleError extends Error { + constructor( + readonly code: InputRuleCode, + message: string, + ) { + super(message) + this.name = 'InputRuleError' + } +} + +const enum C { + Quote = 0x22, + Backslash = 0x5c, + OpenBrace = 0x7b, + CloseBrace = 0x7d, + OpenBracket = 0x5b, + CloseBracket = 0x5d, + Comma = 0x2c, + Colon = 0x3a, + Minus = 0x2d, + Zero = 0x30, + Nine = 0x39, + Dot = 0x2e, + LowerE = 0x65, + UpperE = 0x45, + LowerU = 0x75, +} + +interface Frame { + isObject: boolean + keys: Set | null + expectKey: boolean +} + +const isHigh = (u: number) => u >= 0xd800 && u <= 0xdbff +const isLow = (u: number) => u >= 0xdc00 && u <= 0xdfff + +/** The code unit of the four hex digits at `text[i]`, or -1. `parseInt` would take "12zz" or " 123". */ +function hex4(text: string, i: number): number { + let u = 0 + for (let j = i; j < i + 4; j++) { + const d = text.charCodeAt(j) + const lower = d | 0x20 + if (d >= C.Zero && d <= C.Nine) u = (u << 4) | (d - C.Zero) + else if (lower >= 0x61 && lower <= 0x66) u = (u << 4) | (lower - 0x57) + else return -1 + } + return u +} + +/** + * Check `text` against the input rules: no duplicate object keys (compared after + * unescaping), no integer literal outside ±(2^53 − 1), no lone surrogate (raw or + * `\u`-escaped), nesting no deeper than `maxDepth` (each object or array is one + * level). Throws InputRuleError on the first violation. + * + * Syntax, apart from an unterminated string or a bad `\u` escape, is left to + * `JSON.parse`, as the protocol orders the codes: on malformed input this may + * report a rule violation instead of a syntax error. It always terminates, and + * throws nothing but InputRuleError. + */ +export function scanJson(text: string, maxDepth: number = MAX_JSON_DEPTH): void { + const n = text.length + const stack: Frame[] = [] + let i = 0 + while (i < n) { + const c = text.charCodeAt(i) + if (c === C.Quote) { + const start = i + i++ + let escaped = false + let pendingHigh = false + for (;;) { + if (i >= n) throw new InputRuleError('syntax', 'Unterminated string') + let u = text.charCodeAt(i) + if (u === C.Quote) break + if (u === C.Backslash) { + escaped = true + const e = text.charCodeAt(i + 1) + if (e === C.LowerU) { + u = hex4(text, i + 2) + if (u < 0) throw new InputRuleError('syntax', 'Bad \\u escape') + i += 6 + } else { + // Any other escape is a non-surrogate code unit. An invalid one + // (`\x`) is not a scan rule: JSON.parse reports it as `syntax`. + u = 0 + i += 2 + } + } else { + i++ + } + if (pendingHigh) { + if (isLow(u)) { + pendingHigh = false + continue + } + throw new InputRuleError('lone_surrogate', 'Lone UTF-16 surrogate in string') + } + if (isHigh(u)) pendingHigh = true + else if (isLow(u)) + throw new InputRuleError('lone_surrogate', 'Lone UTF-16 surrogate in string') + } + if (pendingHigh) throw new InputRuleError('lone_surrogate', 'Lone UTF-16 surrogate in string') + i++ // closing quote + const top = stack[stack.length - 1] + if (top?.isObject && top.expectKey) { + const key = escaped ? unescapeKey(text.slice(start, i)) : text.slice(start + 1, i - 1) + // A key that doesn't unescape can't be compared, and can't parse either: + // the scan goes on, so a later rule can still come first, and JSON.parse + // reports `syntax`. + if (key !== null) { + top.keys ??= new Set() + if (top.keys.has(key)) { + throw new InputRuleError('duplicate_key', `Duplicate object key ${JSON.stringify(key)}`) + } + top.keys.add(key) + } + top.expectKey = false + } + continue + } + if (c === C.OpenBrace || c === C.OpenBracket) { + if (stack.length >= maxDepth) { + throw new InputRuleError('too_deep', `JSON nested deeper than ${maxDepth} levels`) + } + stack.push({ isObject: c === C.OpenBrace, keys: null, expectKey: c === C.OpenBrace }) + i++ + continue + } + if (c === C.CloseBrace || c === C.CloseBracket) { + stack.pop() + i++ + continue + } + if (c === C.Comma) { + const top = stack[stack.length - 1] + if (top?.isObject) top.expectKey = true + i++ + continue + } + if (c === C.Minus || (c >= C.Zero && c <= C.Nine)) { + let j = c === C.Minus ? i + 1 : i + const digitsStart = j + while (j < n && text.charCodeAt(j) >= C.Zero && text.charCodeAt(j) <= C.Nine) j++ + const next = text.charCodeAt(j) + const isInteger = next !== C.Dot && next !== C.LowerE && next !== C.UpperE + if (isInteger) { + const len = j - digitsStart + const max = MAX_SAFE_INTEGER_LITERAL + if (len > max.length || (len === max.length && text.slice(digitsStart, j) > max)) { + throw new InputRuleError( + 'unsafe_integer', + `Integer ${text.slice(i, j)} is outside ±(2^53−1); send large integers as strings`, + ) + } + i = j + } else { + // Fraction and/or exponent: skip the rest of the number token. + j++ + while (j < n) { + const d = text.charCodeAt(j) + if ( + (d >= C.Zero && d <= C.Nine) || + d === 0x2b || + d === C.Minus || + d === C.LowerE || + d === C.UpperE || + d === C.Dot + ) + j++ + else break + } + i = j + } + continue + } + // Whitespace, colon, and the literals true/false/null. + i++ + } +} + +/** A quoted key, unescaped; null if it isn't a JSON string (`"\x"`, a raw control character). */ +function unescapeKey(quoted: string): string | null { + try { + return JSON.parse(quoted) as string + } catch { + return null + } +} + +/** `scanJson` then `JSON.parse`. Throws InputRuleError (code `syntax` for parse errors). */ +export function parseStrict(text: string, maxDepth: number = MAX_JSON_DEPTH): unknown { + scanJson(text, maxDepth) + try { + return JSON.parse(text) + } catch (err) { + throw new InputRuleError('syntax', `Invalid JSON: ${(err as Error).message}`) + } +} + +export interface RecordInput { + id: string + type: string + data: unknown + private?: boolean +} + +/** Check a record id against the protocol limits. Returns an error message or null. */ +export function checkRecordId(id: unknown): string | null { + if (typeof id !== 'string' || id.length === 0) return 'Record id must be a non-empty string' + if (utf8ByteLength(id) > MAX_ID_BYTES) return `Record id exceeds ${MAX_ID_BYTES} bytes` + return null +} + +/** + * Check a type slug. Slugs become export entry names (`records/.ndjson`), + * so path separators, control characters and a leading dot are refused. + */ +export function checkTypeSlug(type: unknown): string | null { + if (typeof type !== 'string' || type.length === 0) return 'Type must be a non-empty string' + if (utf8ByteLength(type) > MAX_TYPE_BYTES) return `Type exceeds ${MAX_TYPE_BYTES} bytes` + if (type.startsWith('.')) return 'Type must not start with "."' + for (let i = 0; i < type.length; i++) { + const u = type.charCodeAt(i) + if (u === 0x2f || u === 0x5c || u < 0x20 || u === 0x7f) { + return 'Type must not contain slashes or control characters' + } + } + return null +} + +/** + * Parse one pushed record line, `{"id":…,"type":…,"data":…,"private"?:…}`, under + * the input rules. The envelope counts as one nesting level, so `data` may nest + * MAX_JSON_DEPTH levels deep. Size is checked on the canonical form, which is what + * is stored and hashed; the canonical string is returned so callers don't build it + * twice. + */ +export function parseRecordLine(line: string): RecordInput & { canonical: string } { + const value = parseStrict(line, MAX_JSON_DEPTH + 1) + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new InputRuleError('bad_envelope', 'Record must be a JSON object') + } + const rec = value as Record + const idError = checkRecordId(rec.id) + if (idError) throw new InputRuleError('bad_id', idError) + const typeError = checkTypeSlug(rec.type) + if (typeError) throw new InputRuleError('bad_type', typeError) + if (!('data' in rec)) throw new InputRuleError('bad_envelope', 'Record is missing "data"') + if (rec.private !== undefined && typeof rec.private !== 'boolean') { + throw new InputRuleError('bad_envelope', '"private" must be a boolean') + } + const canonical = recordCanonical(rec.id as string, rec.type as string, rec.data) + if (utf8ByteLength(canonical) > MAX_RECORD_BYTES) { + throw new InputRuleError('record_too_large', `Record exceeds ${MAX_RECORD_BYTES} bytes`) + } + const out: RecordInput & { canonical: string } = { + id: rec.id as string, + type: rec.type as string, + data: rec.data, + canonical, + } + if (rec.private !== undefined) out.private = rec.private as boolean + return out +} diff --git a/packages/protocol/src/jcs.ts b/packages/protocol/src/jcs.ts new file mode 100644 index 0000000..cf934f2 --- /dev/null +++ b/packages/protocol/src/jcs.ts @@ -0,0 +1,54 @@ +/** + * RFC 8785 JSON Canonicalization Scheme (JCS). + * + * JCS is `JSON.stringify` for every primitive (ECMAScript number formatting and + * string escaping) plus object keys sorted by UTF-16 code units. The one trap is + * JavaScript objects themselves: they enumerate integer-like keys ("9", "10") + * first, in numeric order, whatever order they were inserted in. So "sort the keys + * into a new object, then stringify" cannot produce sorted output for those keys. + * This writes objects out as strings instead, and never relies on enumeration + * order. + * + * Inputs come from JSON, so they never hold `undefined`, functions, bigints or + * non-finite numbers. Those throw rather than serialize to something a different + * implementation would not produce. + */ +export function jcs(value: unknown): string { + switch (typeof value) { + case 'string': + return JSON.stringify(value) + case 'boolean': + return value ? 'true' : 'false' + case 'number': + if (!Number.isFinite(value)) throw new TypeError('JCS: non-finite number') + // JSON.stringify renders -0 as "0", which is what JCS requires. + return JSON.stringify(value) + case 'object': { + if (value === null) return 'null' + if (Array.isArray(value)) { + let out = '[' + for (let i = 0; i < value.length; i++) { + if (i > 0) out += ',' + out += jcs(value[i]) + } + return out + ']' + } + const o = value as Record + // Array.prototype.sort() with no comparator compares UTF-16 code units, + // which is exactly JCS's key order. + const keys = Object.keys(o).sort() + let out = '{' + let first = true + for (const k of keys) { + const v = o[k] + if (v === undefined) continue + if (!first) out += ',' + first = false + out += JSON.stringify(k) + ':' + jcs(v) + } + return out + '}' + } + default: + throw new TypeError(`JCS: cannot serialize ${typeof value}`) + } +} diff --git a/packages/protocol/src/repo/build.ts b/packages/protocol/src/repo/build.ts new file mode 100644 index 0000000..dd557fd --- /dev/null +++ b/packages/protocol/src/repo/build.ts @@ -0,0 +1,439 @@ +/** + * Building a version: a base version plus sorted changes per (set, type) become + * new trees, file sets and a root, written to the repository. This is the commit + * engine without the platform: the server publishes the result with + * compare-and-swap (packages/server/src/versions/commit.ts), and the CLI builds + * local versions with it, so both make the same trees from the same changes. + * + * 1. per (set, type): merge the base tree with that set's changes (unchanged + * trees are reused; a type that changes privacy moves its tree); + * 2. removed types release their file references; + * + * Moving a type between sets, or removing one, moves or drops its file + * references by their per-type counts (file-sets.ts): O(files it references). + * On a base whose count trees predate those counts, it reads the type's bodies. + * 3. types whose schema changed are revalidated (O(type size)); + * 4. file sets from reference-count deltas (file-sets.ts); + * 5. the private set object and the root. + * + * Every object is keyed by content, so a failed or abandoned build leaves only + * unreferenced objects. + */ +import { + type Change, + compareUtf8, + diffTrees, + emptySet, + getEntry, + isEmptySet, + iterate, + makeRoot, + mergeTree, + type PrivateSetObject, + type RecordEntry, + recordTree, + type SetObject, + type TreeSummary, + type VersionRoot, +} from '../format.js' +import { + applyFileSet, + FileRefDelta, + type FileSizes, + type SetName, + tracksTypes, + typeFileRefs, +} from './file-sets.js' +import { + bodyOfRecord, + dropRecordBody, + recordPayloadBytes, + type Repo, + RepoSink, + RepoSource, +} from './repo.js' + +export type ChangeSource = Iterable> | AsyncIterable> + +export interface BuildTypeInput { + slug: string + schema: Record + schemaHash: string + /** Sorted changes per set (null: no changes there). Upserts carry `body`. */ + public: ChangeSource | null + private: ChangeSource | null +} + +export interface BuildInput { + /** The base version, with its sets' file reference count trees (writer bookkeeping). */ + base: { hash: string; publicRefsRoot: string | null; privateRefsRoot: string | null } | null + /** The full new type set: types in the base but not here are removed. */ + types: BuildTypeInput[] + /** The full new metadata. */ + metadata: Record | null + /** Changes to declared (possibly unreferenced) files. */ + declaredFiles?: { add: string[]; remove: string[] } + /** The collection's private-set salt. */ + salt: string + /** Sizes of files entering a set; a missing one fails the build (MissingFilesError). */ + fileSizes: FileSizes + /** Validate a record's data against its type's schema; errors or null. Used when a schema changes. */ + validate?: (schema: Record, data: unknown) => string[] | null + /** + * Trees already merged elsewhere (a parallel commit), per type, with the file + * reference deltas and change counts that built them. The types' change + * sources are then ignored. Only when no type changes privacy or schema and + * none is removed. + */ + prebuilt?: { + trees: Record + refs: FileRefDelta + stats: { added: number; removed: number; updated: number } + } +} + +export type BuildResult = + | { + status: 'built' + versionHash: string + root: VersionRoot + privateSet: PrivateSetObject | null + basePublic: SetObject + basePrivate: SetObject + newPublic: SetObject + newPrivate: SetObject + publicRefsRoot: string | null + privateRefsRoot: string | null + /** Files that entered the public set. */ + publicFilesAdded: string[] + stats: { added: number; removed: number; updated: number } + schemaChanged: boolean + recordsChanged: boolean + /** Every key written, in order. */ + written: string[] + } + | { + status: 'invalid' + errors: { recordId: string; type: string; errors: string[] }[] + total: number + } + +const MAX_REPORTED_ERRORS = 100 +const SPILL_BYTES = 4 * 1024 * 1024 + +const summaryOf = (t: { + root: { hash: string; count: number; bytes: number } | null +}): TreeSummary => + t.root + ? { root: t.root.hash, count: t.root.count, bytes: t.root.bytes } + : { root: null, count: 0, bytes: 0 } + +export const isPrivateSchema = (s: Record) => s.private === true + +async function* asAsync(src: Iterable | AsyncIterable): AsyncGenerator { + yield* src as AsyncIterable +} + +/** Merge two sorted change streams; on equal keys the second wins. */ +async function* overlay( + a: AsyncIterable>, + b: AsyncIterable>, +): AsyncGenerator> { + const ai = a[Symbol.asyncIterator]() + const bi = b[Symbol.asyncIterator]() + let x = await ai.next() + let y = await bi.next() + while (!x.done || !y.done) { + if (y.done || (!x.done && compareUtf8(x.value.key, y.value.key) < 0)) { + yield x.value + x = await ai.next() + } else if (x.done || compareUtf8(y.value.key, x.value.key) < 0) { + yield y.value + y = await bi.next() + } else { + yield y.value + x = await ai.next() + y = await bi.next() + } + } +} + +/** Every entry of a tree (with bodies) as upserts. */ +async function* treeAsUpserts( + repo: Repo, + root: string | null, +): AsyncGenerator> { + for await (const e of iterate(new RepoSource(recordTree, repo), root, { payloads: true })) { + yield { key: e.key, entry: e } + } +} + +const summary = (t: TreeSummary | undefined): TreeSummary => + t ? { root: t.root, count: t.count, bytes: t.bytes } : { root: null, count: 0, bytes: 0 } + +export async function buildVersion(repo: Repo, input: BuildInput): Promise { + const base = input.base + const baseRoot = base ? await repo.root(base.hash) : null + const basePublic: SetObject = baseRoot?.public ?? emptySet() + const basePrivate: SetObject = baseRoot?.private + ? await repo.privateSet(baseRoot.private) + : emptySet() + + const source = new RepoSource(recordTree, repo) + const sink = new RepoSink(repo, { bodyOf: bodyOfRecord }) + const builderOpts = { + payloadBytes: recordPayloadBytes, + dropPayload: dropRecordBody, + spillBytes: SPILL_BYTES, + } + const refs = input.prebuilt?.refs ?? new FileRefDelta() + const stats = input.prebuilt ? { ...input.prebuilt.stats } : { added: 0, removed: 0, updated: 0 } + let recordsChanged = stats.added + stats.removed + stats.updated > 0 + + const newPublic: SetObject = emptySet() + const newPrivate: SetObject = emptySet() + + // Stats count per set: a record moving between sets is a removal and an addition. + const merge = async ( + set: SetName, + type: string, + baseTree: string | null, + changes: AsyncIterable>, + ) => { + const result = await mergeTree(source, sink, baseTree, changes, { + ...builderOpts, + onChange: (before, after) => { + refs.record(set, before, after, type) + recordsChanged = true + if (before && after) stats.updated++ + else if (after) stats.added++ + else stats.removed++ + }, + }) + return summaryOf(result) + } + + const inputSlugs = new Set(input.types.map((t) => t.slug)) + let schemaChanged = false + + // Whether each base count tree has per-type counts, and moving a type's references. + const refsRoots: Record = { + public: base?.publicRefsRoot ?? null, + private: base?.privateRefsRoot ?? null, + } + const tracks: Record = { + public: await tracksTypes(repo, refsRoots.public), + private: await tracksTypes(repo, refsRoots.private), + } + /** Move (or with `to` null, drop) a type's file references out of set `from`. */ + const moveRefs = async (from: SetName, to: SetName | null, slug: string, tree: string | null) => { + if (tracks[from]) { + for (const [h, n] of await typeFileRefs(repo, refsRoots[from], slug)) { + refs.add(from, slug, h, -n) + if (to) refs.add(to, slug, h, n) + } + return + } + for await (const e of treeAsUpserts(repo, tree)) { + refs.record(from, e.entry, null, slug) + if (to) refs.record(to, null, e.entry, slug) + } + } + + // Schemas are repository objects too (schemas/.json). + await Promise.all( + input.types.map(async (t) => { + if ((await repo.putSchema(t.schema)) !== t.schemaHash) { + throw new Error(`Schema for ${t.slug} does not match its hash`) + } + }), + ) + sink.written.push(...input.types.map((t) => `schemas/${t.schemaHash}.json`)) + + for (const t of input.types) { + const pubBase = basePublic.types[t.slug] + const privBase = basePrivate.types[t.slug] + if ((pubBase ?? privBase)?.schema !== t.schemaHash) schemaChanged = true + const prebuilt = input.prebuilt?.trees[t.slug] + if (input.prebuilt) { + if (!prebuilt) throw new Error(`Parallel commit has no trees for type ${t.slug}`) + if (isPrivateSchema(t.schema)) { + newPrivate.types[t.slug] = { schema: t.schemaHash, ...prebuilt.private } + } else { + newPublic.types[t.slug] = { schema: t.schemaHash, ...prebuilt.public } + if (prebuilt.private.root) + newPrivate.types[t.slug] = { schema: t.schemaHash, ...prebuilt.private } + } + continue + } + const nowPrivate = isPrivateSchema(t.schema) + const wasPrivateType = !pubBase && !!privBase + const pubChanges = t.public ? asAsync(t.public) : null + const privChanges = t.private ? asAsync(t.private) : null + + let pub: TreeSummary = { root: null, count: 0, bytes: 0 } + let priv: TreeSummary = { root: null, count: 0, bytes: 0 } + const pubRoot = pubBase?.root ?? null + const privRoot = privBase?.root ?? null + + if (nowPrivate) { + // Every record of a private type is in the private set. If the type was + // public, its public records move over: a plain move when the private + // tree was empty, otherwise a merge. + if (pubChanges) + throw new Error(`Type ${t.slug} is private; its changes belong to the private set`) + if (pubRoot && !privRoot) { + // The tree moves as it is (summary() drops the old schema hash), with its + // file references; this push's private changes then merge into it. + await moveRefs('public', 'private', t.slug, pubRoot) + priv = privChanges ? await merge('private', t.slug, pubRoot, privChanges) : summary(pubBase) + } else if (pubRoot) { + // Records in both sets: the union is a merge, O(public part). + priv = await merge( + 'private', + t.slug, + privRoot, + overlay(treeAsUpserts(repo, pubRoot), privChanges ?? asAsync([])), + ) + for await (const e of treeAsUpserts(repo, pubRoot)) + refs.record('public', e.entry, null, t.slug) + } else { + priv = privChanges + ? await merge('private', t.slug, privRoot, privChanges) + : summary(privBase) + } + if (pubRoot) recordsChanged = true + } else if (wasPrivateType) { + // A private type made public: its records become public (per-record flags + // were not kept while the whole type was private), except those this push + // marks private. The tree moves as it is and this push's public changes + // merge into it (trees are canonical, so this is the tree a rebuild makes). + // Stats count every record of the new public tree as added, as a rebuild did. + await moveRefs('private', 'public', t.slug, privRoot) + const before = { ...stats } + pub = await merge('public', t.slug, privRoot, pubChanges ?? asAsync([])) + Object.assign(stats, before) + stats.added += pub.count + priv = privChanges + ? await merge('private', t.slug, null, privChanges) + : { root: null, count: 0, bytes: 0 } + recordsChanged = true + } else { + pub = pubChanges ? await merge('public', t.slug, pubRoot, pubChanges) : summary(pubBase) + priv = privChanges ? await merge('private', t.slug, privRoot, privChanges) : summary(privBase) + } + + if (nowPrivate) { + newPrivate.types[t.slug] = { schema: t.schemaHash, ...priv } + } else { + newPublic.types[t.slug] = { schema: t.schemaHash, ...pub } + if (priv.root) newPrivate.types[t.slug] = { schema: t.schemaHash, ...priv } + } + } + + // Removed types: drop their trees, and release their file references. + for (const [set, base_] of [ + ['public', basePublic], + ['private', basePrivate], + ] as const) { + for (const [slug, entry] of Object.entries(base_.types)) { + if (inputSlugs.has(slug)) continue + schemaChanged = true + await moveRefs(set, null, slug, entry.root) + stats.removed += entry.count + if (entry.count > 0) recordsChanged = true + } + } + await sink.flush() + + // Record ids are unique per type across both sets (docs/protocol-v2.md §4). The + // base holds that, so only ids entering a set can break it: probe the other set + // for each, O(changes × height), and only for types with records in both. + // Parallel commits (prebuilt) take plain types only; their units don't move ids. + if (!input.prebuilt) { + for (const t of input.types) { + const pub = newPublic.types[t.slug]?.root ?? null + const priv = newPrivate.types[t.slug]?.root ?? null + if (!pub || !priv) continue + for (const [before, after, other] of [ + [basePublic.types[t.slug]?.root ?? null, pub, priv], + [basePrivate.types[t.slug]?.root ?? null, priv, pub], + ] as const) { + for await (const d of diffTrees(source, before, after)) { + if (d.before || !d.after) continue + if (await getEntry(source, other, d.key)) { + throw new Error( + `Record ${JSON.stringify(d.key)} of type ${t.slug} is in both the public and private sets`, + ) + } + } + } + } + } + + // Revalidate types whose schema changed (records pushed earlier were checked + // against the old schema). O(type size), inherent to a schema change. + const errors: { recordId: string; type: string; errors: string[] }[] = [] + let errorCount = 0 + if (input.validate) { + for (const t of input.types) { + const before = basePublic.types[t.slug] ?? basePrivate.types[t.slug] + if (!before || before.schema === t.schemaHash) continue + for (const tree of [newPublic.types[t.slug], newPrivate.types[t.slug]]) { + for await (const e of iterate(source, tree?.root ?? null, { payloads: true })) { + const errs = input.validate(t.schema, (JSON.parse(e.body!) as { data: unknown }).data) + if (errs) { + errorCount++ + if (errors.length < MAX_REPORTED_ERRORS) + errors.push({ recordId: e.key, type: t.slug, errors: errs }) + } + } + } + } + } + if (errorCount > 0) return { status: 'invalid', errors, total: errorCount } + + // File sets. + await refs.resolve(repo) + const pubFiles = await applyFileSet( + repo, + { refsRoot: base?.publicRefsRoot ?? null, files: basePublic.files }, + refs.refs.public, + null, + input.fileSizes, + refs.typed.public, + ) + const privFiles = await applyFileSet( + repo, + { refsRoot: base?.privateRefsRoot ?? null, files: basePrivate.files }, + refs.refs.private, + input.declaredFiles ?? null, + input.fileSizes, + refs.typed.private, + ) + newPublic.files = pubFiles.files + newPrivate.files = privFiles.files + + // Root. + const privSet: PrivateSetObject = { ...newPrivate, salt: input.salt } + const root = makeRoot(input.metadata, newPublic, isEmptySet(newPrivate) ? null : privSet) + if (root.private) await repo.putPrivateSet(privSet) + const versionHash = await repo.putRoot(root) + return { + status: 'built', + versionHash, + root, + privateSet: root.private ? privSet : null, + basePublic, + basePrivate, + newPublic, + newPrivate, + publicRefsRoot: pubFiles.refsRoot, + privateRefsRoot: privFiles.refsRoot, + publicFilesAdded: pubFiles.added, + stats, + schemaChanged, + recordsChanged, + written: sink.written, + } +} diff --git a/packages/protocol/src/repo/file-sets.ts b/packages/protocol/src/repo/file-sets.ts new file mode 100644 index 0000000..b393b91 --- /dev/null +++ b/packages/protocol/src/repo/file-sets.ts @@ -0,0 +1,320 @@ +/** + * Keeping each set's file tree right in O(changes) (edge-redesign-build.md, + * finding 5). + * + * Protocol (docs/protocol-v2.md §9): a file belongs to every set that has a + * record referencing it; a file declared in a push but referenced by no record + * belongs to the private set. To know when a file leaves a set, each set keeps a + * sidecar count tree (not protocol, a writer's bookkeeping) next to the version: + * + * "r:" → how many of the set's records reference the file + * "d:" → 1 when the file is declared (private set only) + * "t:/" → how many of the set's records of that type reference it + * "m:types" → 1: this tree carries the "t:" keys + * + * The per-type counts let a type move between sets, or be removed, by moving + * its counts: O(files the type references), with no record bodies read. Trees + * written before them have no "m:types" marker; on those, moves and removals + * read the type's bodies as before, until the tree is rebuilt (rebuildFileRefs). + * A null tree (no references at all) needs no marker. + * + * A commit turns record changes into count deltas, applies them to the count + * tree, and adds or removes files from the protocol file tree only when a file's + * presence actually flips. + */ +import { + type Change, + compareUtf8, + type CountEntry, + countTree, + type FileEntry, + fileRefs, + fileTree, + getEntry, + iterate, + mergeTree, + type RecordEntry, + recordTree, + type SetObject, + type TreeSummary, +} from '../format.js' +import { outOfLineHash, type Repo, RepoSink, RepoSource } from './repo.js' + +export type SetName = 'public' | 'private' + +const REF = 'r:' +const DECLARED = 'd:' +const TYPED = 't:' +const MARKER = 'm:types' + +/** A per-type key's suffix: type slugs can't contain "/" (input rules). */ +const typedKey = (type: string, hash: string) => `${type}/${hash}` + +/** + * Accumulates reference count changes from record changes, per set. + * + * A record stored out of line arrives as a pointer line when a writer adds it + * (its body isn't in hand) but as its full body when a reader hands it back. + * Pointers are counted by record hash here and turned into file references by + * `resolve`, which must run before `refs` is used. + */ +export class FileRefDelta { + /** Per set: file hash → change in references. */ + readonly refs: Record> = { public: new Map(), private: new Map() } + /** Per set: "/" → change in that type's references. */ + readonly typed: Record> = { public: new Map(), private: new Map() } + /** Per set: "/" of out-of-line records, until resolve(). */ + readonly #pointers: Record> = { + public: new Map(), + private: new Map(), + } + + /** Change a type's references to a file by `n` (and the set's with it). */ + add(set: SetName, type: string, hash: string, n: number): void { + if (n === 0) return + this.refs[set].set(hash, (this.refs[set].get(hash) ?? 0) + n) + const k = typedKey(type, hash) + this.typed[set].set(k, (this.typed[set].get(k) ?? 0) + n) + } + + #refsOf(set: SetName, type: string, body: string, by: number) { + // Cheap prefilter: most records reference no files. + if (!body.includes('"$file"')) return + for (const h of fileRefs((JSON.parse(body) as { data: unknown }).data)) + this.add(set, type, h, by) + } + + #body(set: SetName, type: string, body: string | undefined, by: number) { + if (body === undefined) throw new Error('File reference accounting needs record bodies') + const pointer = outOfLineHash(body) + if (pointer) { + const k = typedKey(type, pointer) + this.#pointers[set].set(k, (this.#pointers[set].get(k) ?? 0) + by) + } else this.#refsOf(set, type, body, by) + } + + /** Feed one record change of a type in a set (mergeTree's onChange). */ + record(set: SetName, before: RecordEntry | null, after: RecordEntry | null, type: string): void { + if (before) this.#body(set, type, before.body, -1) + if (after) this.#body(set, type, after.body, +1) + } + + /** Count the file references of out-of-line records seen as pointers. */ + async resolve(repo: Repo): Promise { + for (const set of ['public', 'private'] as const) { + for (const [k, n] of this.#pointers[set]) { + if (n === 0) continue + const slash = k.lastIndexOf('/') + this.#refsOf(set, k.slice(0, slash), await repo.outOfLineRecord(k.slice(slash + 1)), n) + } + this.#pointers[set].clear() + } + } + + get touched(): boolean { + const all = [this.refs, this.#pointers].flatMap((r) => [ + ...r.public.values(), + ...r.private.values(), + ]) + return all.some((n) => n !== 0) + } +} + +/** Whether a count tree carries per-type counts (a null tree trivially does). */ +export async function tracksTypes(repo: Repo, refsRoot: string | null): Promise { + if (refsRoot === null) return true + return !!(await getEntry(new RepoSource(countTree, repo), refsRoot, MARKER)) +} + +/** One type's references per file, from a count tree that tracks types. O(those files). */ +export async function typeFileRefs( + repo: Repo, + refsRoot: string | null, + type: string, +): Promise> { + const out = new Map() + const prefix = TYPED + typedKey(type, '') + for await (const e of iterate(new RepoSource(countTree, repo), refsRoot, { after: prefix })) { + if (!e.key.startsWith(prefix)) break + out.set(e.key.slice(prefix.length), e.n) + } + return out +} + +export interface FileSetResult { + files: TreeSummary + refsRoot: string | null + /** Files that entered the set in this commit (for the cumulative public files tree). */ + added: string[] +} + +export class MissingFilesError extends Error { + constructor(readonly hashes: string[]) { + super(`${hashes.length} referenced file(s) are not uploaded`) + this.name = 'MissingFilesError' + } +} + +/** Sizes of files by hash; a hash it leaves out is a file the store doesn't have. */ +export type FileSizes = (hashes: string[]) => Promise> + +/** The declared-file markers currently in a count tree. O(declared files). */ +export async function declaredFiles(repo: Repo, refsRoot: string | null): Promise> { + const out = new Set() + for await (const e of iterate(new RepoSource(countTree, repo), refsRoot, { after: DECLARED })) { + if (!e.key.startsWith(DECLARED)) break + out.add(e.key.slice(DECLARED.length)) + } + return out +} + +/** How many of a set's records reference each file, from its count tree. O(files). */ +export async function referenceCounts( + repo: Repo, + refsRoot: string | null, + /** Only files `from` … `through` (inclusive): O(that range), not the whole tree. */ + range?: { from: string; through: string }, +): Promise> { + const out = new Map() + // A key just before REF + from: the same with its last character dropped. + const after = range ? REF + range.from.slice(0, -1) : REF + for await (const e of iterate(new RepoSource(countTree, repo), refsRoot, { after })) { + if (!e.key.startsWith(REF)) break + const hash = e.key.slice(REF.length) + if (range && hash > range.through) break + if (!range || hash >= range.from) out.set(hash, e.n) + } + return out +} + +/** + * Apply a set's reference deltas (and, for the private set, declared-file + * changes) to its count tree and file tree. + */ +export async function applyFileSet( + repo: Repo, + base: { refsRoot: string | null; files: TreeSummary }, + refDeltas: Map, + declared: { add: string[]; remove: string[] } | null, + fileSizes: FileSizes, + /** Per-type deltas ("/"), kept when the base tree tracks types. */ + typedDeltas?: Map, +): Promise { + const counts = new RepoSource(countTree, repo) + const keyDelta = new Map() + for (const [h, d] of refDeltas) if (d !== 0) keyDelta.set(REF + h, d) + for (const h of declared?.add ?? []) + keyDelta.set(DECLARED + h, (keyDelta.get(DECLARED + h) ?? 0) + 1) + for (const h of declared?.remove ?? []) + keyDelta.set(DECLARED + h, (keyDelta.get(DECLARED + h) ?? 0) - 1) + if ([...keyDelta.values()].every((d) => d === 0)) { + return { files: base.files, refsRoot: base.refsRoot, added: [] } + } + + // Per-type counts, only on a tree that has them all (see the header). + const countChanges: Change[] = [] + if (typedDeltas && (await tracksTypes(repo, base.refsRoot))) { + for (const [k, d] of typedDeltas) { + if (d === 0) continue + const key = TYPED + k + const old = (await getEntry(counts, base.refsRoot, key))?.n ?? 0 + const n = old + d + if (n < 0) throw new Error(`Per-type file reference count for ${k} went negative`) + countChanges.push({ key, entry: n > 0 ? { key, n } : null }) + } + if (base.refsRoot === null) countChanges.push({ key: MARKER, entry: { key: MARKER, n: 1 } }) + } + + // New counts, and whether each touched file is present before and after. + const presence = new Map() + const hashesTouched = new Set([...keyDelta.keys()].map((k) => k.slice(2))) + for (const h of hashesTouched) { + const p = { before: false, after: false } + for (const prefix of [REF, DECLARED]) { + const key = prefix + h + const old = (await getEntry(counts, base.refsRoot, key))?.n ?? 0 + const now = old + (keyDelta.get(key) ?? 0) + if (prefix === REF && now < 0) throw new Error(`File reference count for ${h} went negative`) + // Declaring is idempotent: a marker is 0 or 1. + const n = prefix === DECLARED ? Math.min(1, Math.max(0, now)) : now + if (n !== old) countChanges.push({ key, entry: n > 0 ? { key, n } : null }) + p.before ||= old > 0 + p.after ||= n > 0 + } + presence.set(h, p) + } + countChanges.sort((a, b) => compareUtf8(a.key, b.key)) + + const entering = [...presence].filter(([, p]) => !p.before && p.after).map(([h]) => h) + const leaving = [...presence].filter(([, p]) => p.before && !p.after).map(([h]) => h) + const sizes = await fileSizes(entering) + const missing = entering.filter((h) => !sizes.has(h)) + if (missing.length > 0) throw new MissingFilesError(missing) + + const fileChanges: Change[] = [ + ...entering.map((h) => ({ key: h, entry: { key: h, size: sizes.get(h)! } })), + ...leaving.map((h) => ({ key: h, entry: null })), + ].sort((a, b) => compareUtf8(a.key, b.key)) + + const sink = new RepoSink(repo) + const refs = await mergeTree(counts, sink, base.refsRoot, countChanges) + const fileSink = new RepoSink(repo) + const files = await mergeTree( + new RepoSource(fileTree, repo), + fileSink, + base.files.root, + fileChanges, + ) + await Promise.all([sink.flush(), fileSink.flush()]) + // A tree left holding only its marker is no tree: nothing references a file. + const marked = refs.root?.count === 1 && (await getEntry(counts, refs.root.hash, MARKER)) + return { + refsRoot: marked ? null : (refs.root?.hash ?? null), + files: files.root + ? { root: files.root.hash, count: files.root.count, bytes: files.root.bytes } + : { root: null, count: 0, bytes: 0 }, + added: entering, + } +} + +/** + * The file reference count trees of a version, rebuilt from its records (they + * are writer bookkeeping and travel with no sync or mirror). Every + * reference counts, and a private-set file no record references was declared. + * Throws if the rebuilt file sets differ from the version's. + */ +export async function rebuildFileRefs( + repo: Repo, + sets: { public: SetObject; private: SetObject }, +): Promise<{ public: string | null; private: string | null }> { + const out = { public: null as string | null, private: null as string | null } + for (const name of ['public', 'private'] as const) { + const set = sets[name] + const delta = new FileRefDelta() + for (const [slug, t] of Object.entries(set.types)) { + for await (const e of iterate(new RepoSource(recordTree, repo), t.root, { payloads: true })) { + delta.record(name, null, e, slug) + } + } + await delta.resolve(repo) + const counts = delta.refs[name] + const sizes = new Map() + for await (const e of iterate(new RepoSource(fileTree, repo), set.files.root)) { + sizes.set(e.key, e.size) + } + const declared = name === 'private' ? [...sizes.keys()].filter((h) => !counts.has(h)) : [] + const result = await applyFileSet( + repo, + { refsRoot: null, files: { root: null, count: 0, bytes: 0 } }, + counts, + declared.length > 0 ? { add: declared, remove: [] } : null, + async (hs) => new Map(hs.filter((h) => sizes.has(h)).map((h) => [h, sizes.get(h)!])), + delta.typed[name], + ) + if (result.files.root !== set.files.root) { + throw new Error(`The ${name} file set doesn't match its records' references`) + } + out[name] = result.refsRoot + } + return out +} diff --git a/packages/protocol/src/repo/fsck.ts b/packages/protocol/src/repo/fsck.ts new file mode 100644 index 0000000..abba1e8 --- /dev/null +++ b/packages/protocol/src/repo/fsck.ts @@ -0,0 +1,434 @@ +import { hashSchema } from '../hash.js' +/** + * fsck for a repository: everything a reader trusts, checked from storage. + * + * - the collection's signed log, when a collection is named: entries chain, + * verify under the given keys (else the keys collection.json declares, which + * proves integrity but not origin), and end at head.json; + * - each version's root and set objects (shape), and its schemas (hash); + * - each record and file tree, structurally (verifyTree: every node, every + * chunking rule), and its count and bytes against the set's summary; + * - every leaf body: each line is its entry's record (Repo.bodyLines); + * - every file the file trees list: present at its size, and with + * `fileBytes`, hashed. + * + * Trees and leaves shared between versions are checked once. Problems are + * collected, not thrown, so one bad object doesn't hide the rest. It reads the + * repository's store directly: no cache answers for it, and every object is + * hash-checked. + */ +import { checkSetObject, type SetObject } from '../root.js' +import { sha256Hasher } from '../sha256.js' +import { mergeTree } from '../tree/merge.js' +import { fileTree, recordTree, type RecordEntry } from '../tree/node.js' +import { diffTrees, leaves, newNodes } from '../tree/read.js' +import { verifyTree } from '../tree/verify.js' +import { + type PublicKeyInfo, + readCollectionInfo, + readHead, + readLogEntry, + verifyLog, + verifyLogEntries, +} from './log.js' +import { keys, openRepo, type Repo, RepoSource } from './repo.js' + +export interface FsckOptions { + /** Version hashes to check; with a collection id and none given, the log's. */ + versions?: string[] + /** Check this collection's log (collections//). */ + collectionId?: string + /** Keys the log must be signed by; default: those its collection.json declares. */ + trustedKeys?: PublicKeyInfo[] + /** Read and hash every file's bytes, not just check it's there at its size. */ + fileBytes?: boolean + maxErrors?: number +} + +export interface FsckReport { + ok: boolean + errors: string[] + /** Problems past maxErrors, not listed. */ + moreErrors: number + versions: number + trees: number + nodes: number + leaves: number + records: number + files: number + /** How the log was checked: given keys, collection.json's own, or not at all. */ + log: 'trusted keys' | 'declared keys' | 'not checked' +} + +export async function fsck(given: Repo, opts: FsckOptions = {}): Promise { + // Storage itself: no shared cache or memory of earlier reads, and every object + // hash-checked as if from a location we don't operate. + const repo = openRepo(given.blobs, { trusted: false }) + const max = opts.maxErrors ?? 100 + const report: FsckReport = { + ok: true, + errors: [], + moreErrors: 0, + versions: 0, + trees: 0, + nodes: 0, + leaves: 0, + records: 0, + files: 0, + log: 'not checked', + } + const fail = (msg: string) => { + report.ok = false + if (report.errors.length < max) report.errors.push(msg) + else report.moreErrors++ + } + const attempt = async (what: string, f: () => Promise): Promise => { + try { + return await f() + } catch (err) { + fail(`${what}: ${(err as Error).message}`) + return null + } + } + + // The log. + let versions = opts.versions ?? [] + if (opts.collectionId) { + const id = opts.collectionId + const info = await attempt('collection.json', () => readCollectionInfo(repo, id)) + if (!info) { + if (!report.errors.length) fail('collection.json is missing') + } else { + const keys_ = opts.trustedKeys ?? info.keys + report.log = opts.trustedKeys ? 'trusted keys' : 'declared keys' + await attempt('log', () => verifyLog(repo, id, keys_)) + if (!opts.versions) { + const head = await readHead(repo, id) + const hashes: string[] = [] + for (let seq = 1; seq <= (head?.seq ?? 0); seq++) { + const e = await readLogEntry(repo, id, seq) + if (e) hashes.push(e.versionHash) + } + versions = hashes + } + } + } + + const records = new RepoSource(recordTree, repo) + const files = new RepoSource(fileTree, repo) + const seenTrees = new Set() + const seenLeaves = new Set() + const seenFiles = new Set() + const seenSchemas = new Set() + + const checkSet = async (where: string, set: SetObject) => { + for (const [slug, t] of Object.entries(set.types)) { + if (!seenSchemas.has(t.schema)) { + seenSchemas.add(t.schema) + const body = await attempt(`${where} type ${slug} schema`, () => repo.schema(t.schema)) + if (body && hashSchema(body) !== t.schema) fail(`schema ${t.schema} fails its hash`) + } + if (!t.root || seenTrees.has(t.root)) continue + seenTrees.add(t.root) + report.trees++ + const v = await verifyTree(records, t.root) + report.nodes += v.nodes + for (const e of v.errors) fail(`${where} type ${slug}: ${e}`) + if (v.ok && (v.count !== t.count || v.bytes !== t.bytes)) + fail( + `${where} type ${slug}: tree has ${v.count} records / ${v.bytes} bytes, summary says ${t.count} / ${t.bytes}`, + ) + report.records += v.count + // Every body line is its entry's record. + for await (const leaf of leaves(records, t.root)) { + if (seenLeaves.has(leaf.hash)) continue + seenLeaves.add(leaf.hash) + report.leaves++ + const node = await attempt(`leaf ${leaf.hash}`, () => records.node(leaf.hash)) + if (!node || node.kind !== 'leaf') continue + await attempt(`body of ${leaf.hash}`, () => + repo.bodyLines({ hash: leaf.hash, entries: node.entries as RecordEntry[] }), + ) + } + } + const f = set.files + if (f.root && !seenTrees.has(f.root)) { + seenTrees.add(f.root) + report.trees++ + const v = await verifyTree(files, f.root) + report.nodes += v.nodes + for (const e of v.errors) fail(`${where} files: ${e}`) + if (v.ok && (v.count !== f.count || v.bytes !== f.bytes)) + fail( + `${where} files: tree has ${v.count} / ${v.bytes}, summary says ${f.count} / ${f.bytes}`, + ) + for await (const leaf of leaves(files, f.root)) { + const node = await files.node(leaf.hash) + if (node.kind !== 'leaf') continue + for (const e of node.entries) { + if (seenFiles.has(e.key)) continue + seenFiles.add(e.key) + report.files++ + await checkFile(e.key, e.size) + } + } + } + } + + const checkFile = async (hash: string, size: number) => { + const key = keys.file(hash) + if (!opts.fileBytes) { + const head = await attempt(`file ${hash}`, () => repo.blobs.head(key)) + if (!head) fail(`file ${hash} is missing`) + else if (head.size !== size) fail(`file ${hash} is ${head.size} bytes, listed as ${size}`) + return + } + const obj = await attempt(`file ${hash}`, () => repo.blobs.get(key)) + if (!obj) return fail(`file ${hash} is missing`) + const h = sha256Hasher() + let n = 0 + const reader = obj.body.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + h.update(value) + n += value.byteLength + } + if (n !== size || h.hex() !== hash) fail(`file ${hash} fails its hash or size`) + } + + for (const hash of versions) { + report.versions++ + const where = `version ${hash.slice(0, 17)}…` + const root = await attempt(where, () => repo.root(hash)) + if (!root) continue + const pubErr = checkSetObject(root.public) + if (pubErr) fail(`${where} public ${pubErr}`) + else await checkSet(`${where} public`, root.public) + if (root.private) { + // A public-only copy (a mirror of public sets) lacks the private set: not an error. + if (!(await repo.blobs.head(keys.privateSet(root.private)))) continue + const priv = await attempt(`${where} private set`, () => repo.privateSet(root.private!)) + if (!priv) continue + const privErr = checkSetObject(priv, true) + if (privErr) fail(`${where} private ${privErr}`) + else await checkSet(`${where} private`, priv) + } + } + return report +} + +// --- Resumable fsck --------------------------------------------------------------------- + +/** Where a resumable check stopped: the last version checked, and the log chain there. */ +export interface FsckCursor { + seq: number + log: { seq: number; entryHash: string } | null + /** The last version checked; the next is checked against it. */ + versionHash: string | null +} + +export interface FsckStepOptions { + collectionId: string + /** Keys the log must be signed by. */ + trustedKeys: PublicKeyInfo[] + fileBytes?: boolean + /** Where the last step stopped (null: from the start). */ + cursor: FsckCursor | null + /** Stop after the version that takes the changes checked past this (at least one version). */ + changeBudget?: number + maxErrors?: number +} + +/** + * Check a collection's log and versions a few at a time, so a check of any size + * fits in bounded jobs. Each version is checked against the one before, in + * O(its changes): a tree is canonical when merging its diff from the previous + * (already checked) tree into that tree rebuilds exactly its root, and only its + * new leaves' bodies and new files are read. The first version is checked + * against nothing, so it costs O(its size). With the last version, head.json + * must match the last entry. Returns the step's report (the caller adds steps + * up) and the next cursor, null when done. + */ +export async function fsckStep( + given: Repo, + opts: FsckStepOptions, +): Promise<{ report: FsckReport; cursor: FsckCursor | null }> { + const repo = openRepo(given.blobs, { trusted: false }) + const max = opts.maxErrors ?? 100 + const budget = opts.changeBudget ?? 1_000_000 + const report: FsckReport = { + ok: true, + errors: [], + moreErrors: 0, + versions: 0, + trees: 0, + nodes: 0, + leaves: 0, + records: 0, + files: 0, + log: 'trusted keys', + } + const fail = (msg: string) => { + report.ok = false + if (report.errors.length < max) report.errors.push(msg) + else report.moreErrors++ + } + const attempt = async (what: string, f: () => Promise): Promise => { + try { + return await f() + } catch (err) { + fail(`${what}: ${(err as Error).message}`) + return null + } + } + const id = opts.collectionId + const head = await attempt('head.json', () => readHead(repo, id)) + if (!head) { + if (report.ok) fail('head.json is missing') + return { report, cursor: null } + } + const records = new RepoSource(recordTree, repo) + const files = new RepoSource(fileTree, repo) + let cursor: FsckCursor = opts.cursor ?? { seq: 0, log: null, versionHash: null } + const noSink = { leaf() {}, interior() {} } + + /** Whether `root` is the canonical tree its diff from `base` makes; counts its changes. */ + const checkTree = async ( + where: string, + source: RepoSource, + base: string | null, + root: string | null, + onAdded?: (e: E) => Promise, + ): Promise<{ changes: number; count: number; bytes: number } | null> => { + if (base === root) return null + report.trees++ + let changes = 0 + const diff = async function* () { + for await (const d of diffTrees(source, base, root)) { + changes++ + if (d.after && onAdded) await onAdded(d.after) + yield { key: d.key, entry: d.after } + } + } + const merged = await attempt(where, () => mergeTree(source, noSink, base, diff())) + if (!merged) return null + if ((merged.root?.hash ?? null) !== root) { + fail(`${where}: not canonical (its entries make ${merged.root?.hash ?? 'an empty tree'})`) + return null + } + for await (const n of newNodes(source, base, root)) { + report.nodes++ + if (n.level === 0) report.leaves++ + } + return { changes, count: merged.root?.count ?? 0, bytes: merged.root?.bytes ?? 0 } + } + + const checkFile = async (hash: string, size: number) => { + report.files++ + const key = keys.file(hash) + if (!opts.fileBytes) { + const h = await attempt(`file ${hash}`, () => repo.blobs.head(key)) + if (!h) fail(`file ${hash} is missing`) + else if (h.size !== size) fail(`file ${hash} is ${h.size} bytes, listed as ${size}`) + return + } + const obj = await attempt(`file ${hash}`, () => repo.blobs.get(key)) + if (!obj) return fail(`file ${hash} is missing`) + const hasher = sha256Hasher() + let n = 0 + const reader = obj.body.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + hasher.update(value) + n += value.byteLength + } + if (n !== size || hasher.hex() !== hash) fail(`file ${hash} fails its hash or size`) + } + + const checkSet = async (where: string, set: SetObject, base: SetObject | null) => { + let changes = 0 + for (const [slug, t] of Object.entries(set.types)) { + const was = base?.types[slug] + if (was?.schema !== t.schema) { + const body = await attempt(`${where} type ${slug} schema`, () => repo.schema(t.schema)) + if (body && hashSchema(body) !== t.schema) fail(`schema ${t.schema} fails its hash`) + } + const r = await checkTree(`${where} type ${slug}`, records, was?.root ?? null, t.root) + if (!r) continue + changes += r.changes + report.records += r.changes + if (r.count !== t.count || r.bytes !== t.bytes) + fail( + `${where} type ${slug}: tree has ${r.count} records / ${r.bytes} bytes, summary says ${t.count} / ${t.bytes}`, + ) + // Every new leaf's body is its entries' records. + for await (const n of newNodes(records, was?.root ?? null, t.root)) { + if (n.level !== 0) continue + const node = await attempt(`leaf ${n.hash}`, () => records.node(n.hash)) + if (!node || node.kind !== 'leaf') continue + await attempt(`body of ${n.hash}`, () => + repo.bodyLines({ hash: n.hash, entries: node.entries as RecordEntry[] }), + ) + } + } + const f = set.files + const fr = await checkTree(`${where} files`, files, base?.files.root ?? null, f.root, (e) => + checkFile(e.key, e.size), + ) + if (fr) { + changes += fr.changes + if (fr.count !== f.count || fr.bytes !== f.bytes) + fail( + `${where} files: tree has ${fr.count} / ${fr.bytes}, summary says ${f.count} / ${f.bytes}`, + ) + } + return changes + } + + const setsOf = async (hash: string) => { + const root = await repo.root(hash) + // A public-only copy (a mirror of public sets) lacks the private set: not an error. + const priv = + root.private && (await repo.blobs.head(keys.privateSet(root.private))) + ? await repo.privateSet(root.private) + : null + return { root, priv } + } + + let spent = 0 + while (cursor.seq < head.seq && (spent < budget || report.versions === 0)) { + const seq = cursor.seq + 1 + const where = `version ${seq}` + const entry = await attempt(`log entry ${seq}`, () => readLogEntry(repo, id, seq)) + if (!entry) { + if (report.ok) fail(`Log entry ${seq} is missing`) + return { report, cursor: null } + } + const log = await attempt('log', () => + verifyLogEntries([entry], opts.trustedKeys, cursor.log, id), + ) + if (!log) return { report, cursor: null } + report.versions++ + const now = await attempt(where, () => setsOf(entry.versionHash)) + const before = cursor.versionHash + ? await attempt(`version ${seq - 1}`, () => setsOf(cursor.versionHash!)) + : null + if (now) { + const pubErr = checkSetObject(now.root.public) + if (pubErr) fail(`${where} public ${pubErr}`) + else spent += await checkSet(`${where} public`, now.root.public, before?.root.public ?? null) + if (now.priv) { + const privErr = checkSetObject(now.priv, true) + if (privErr) fail(`${where} private ${privErr}`) + else spent += await checkSet(`${where} private`, now.priv, before?.priv ?? null) + } + } + cursor = { seq, log, versionHash: entry.versionHash } + } + if (cursor.seq < head.seq) return { report, cursor } + if (cursor.log?.entryHash !== head.entryHash || cursor.versionHash !== head.versionHash) + fail('head.json does not match the last log entry') + return { report, cursor: null } +} diff --git a/packages/protocol/src/repo/gzip.ts b/packages/protocol/src/repo/gzip.ts new file mode 100644 index 0000000..ad07903 --- /dev/null +++ b/packages/protocol/src/repo/gzip.ts @@ -0,0 +1,98 @@ +/** + * Gzip with the Web Streams compression API, and gunzip with native zlib where + * the runtime has it. + * + * A large leaf body is several gzip members in one object (repository layout). + * Node's zlib and its DecompressionStream read concatenated members; workerd's + * DecompressionStream stops after the first and throws "Trailing bytes after + * end of compressed data" (found on staging, 2026-10-04). Node and workerd + * (nodejs_compat) both expose zlib through `process.getBuiltinModule`, so it is + * used there. Browsers use DecompressionStream, which may stop after one member, + * so their path splits the members itself (gunzipMembers). + */ + +async function pipe( + bytes: Uint8Array, + t: CompressionStream | DecompressionStream, +): Promise { + const stream = new Blob([bytes as Uint8Array]).stream().pipeThrough(t) + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +const enc = new TextEncoder() +const dec = new TextDecoder() + +export const gzip = (data: Uint8Array | string) => + pipe(typeof data === 'string' ? enc.encode(data) : data, new CompressionStream('gzip')) + +interface Zlib { + gunzip(bytes: Uint8Array, done: (err: Error | null, out: Uint8Array) => void): void +} +const zlib = ( + globalThis as { process?: { getBuiltinModule?: (id: 'node:zlib') => Zlib | undefined } } +).process?.getBuiltinModule?.('node:zlib') + +export const gunzip = (bytes: Uint8Array): Promise => + zlib + ? new Promise((resolve, reject) => + zlib.gunzip(bytes, (err, out) => + err ? reject(err) : resolve(new Uint8Array(out.buffer, out.byteOffset, out.byteLength)), + ), + ) + : gunzipMembers(bytes, (b) => pipe(b, new DecompressionStream('gzip'))) + +/** + * Gunzip with a decompressor that may read only one member (browsers' + * DecompressionStream). Whole input first; if that fails, the members are found + * one at a time: a member ends where a later gzip header starts and the bytes + * up to it decode on their own (a slice cut anywhere else is truncated or has + * trailing bytes). Exported for the test that stands in a one-member decoder. + */ +export async function gunzipMembers( + bytes: Uint8Array, + oneMember: (b: Uint8Array) => Promise, +): Promise { + try { + return await oneMember(bytes) + } catch (first) { + const parts: Uint8Array[] = [] + let start = 0 + next: while (start < bytes.length) { + // A member is at least an 18-byte header and trailer. + for (let end = start + 18; end <= bytes.length; end++) { + const atHeader = + end === bytes.length || + (bytes[end] === 0x1f && bytes[end + 1] === 0x8b && bytes[end + 2] === 0x08) + if (!atHeader) continue + try { + parts.push(await oneMember(bytes.subarray(start, end))) + start = end + continue next + } catch { + // Not this member's end: a header inside compressed data. + } + } + throw first + } + const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0)) + let off = 0 + for (const p of parts) { + out.set(p, off) + off += p.byteLength + } + return out + } +} + +export const gunzipText = async (bytes: Uint8Array) => dec.decode(await gunzip(bytes)) + +export const isGzip = (bytes: Uint8Array) => + bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b + +/** Split decompressed NDJSON into lines (a trailing newline ends the last line). */ +export function splitLines(text: string): string[] { + if (text.length === 0) return [] + const lines = text.split('\n') + if (lines[lines.length - 1] === '') lines.pop() + return lines +} diff --git a/packages/protocol/src/repo/log.ts b/packages/protocol/src/repo/log.ts new file mode 100644 index 0000000..fa0527b --- /dev/null +++ b/packages/protocol/src/repo/log.ts @@ -0,0 +1,270 @@ +/** + * The per-collection version log: one signed, hash-chained entry per version. + * + * It lets anyone holding a copy (a mirror, a clone, a frontend) check the + * version history without the platform database, and it is the per-collection + * head log from the plan's Security notes: entries chain by `prev`, so a server + * can't silently drop or reorder versions for one reader and not another. + * + * entry = {collectionId, seq, semver, versionHash, baseSemver, message, appId, + * actorId, createdAt, prev, keyId, sig} + * signed = JCS(entry without "sig"), Ed25519, "sig" = base64url + * entryHash = sha256(JCS(entry)) (with "sig") + * prev = entryHash of seq − 1, or null for seq 1 + * head.json = {"seq", "entryHash", "versionHash"}, overwritten after the entry is written + * + * Pusher identity is omitted from the log (an open question in the plan). The + * collection id is signed, so one collection's entries never verify as another's. + */ +import { jcs, sha256Hex } from '../format.js' +import { IntegrityError, keys, type Repo } from './repo.js' + +export interface LogEntry { + /** The collection whose log this is. */ + collectionId: string + seq: number + semver: string + versionHash: string + baseSemver: string | null + message: string | null + appId: string | null + actorId: string | null + /** ISO 8601, UTC. */ + createdAt: string + prev: string | null + keyId: string + sig: string +} + +export type UnsignedEntry = Omit + +export interface Head { + seq: number + entryHash: string + versionHash: string +} + +/** + * collection.json (spec 11.1): what a repository says about a collection without + * a server. Neither hashed nor signed; rewritten whenever a member changes. + */ +export interface CollectionInfo { + id: string + owner: CollectionOwner + slug: string + name: string + description: string | null + visibility: Visibility + /** `ark:/`, or null. */ + ark: string | null + /** Public keys that sign this collection's log. */ + keys: PublicKeyInfo[] + [k: string]: unknown +} + +/** The organization that owns a collection. */ +export interface CollectionOwner { + /** Its identifier at the server that wrote collection.json. */ + id: string + did: string | null + handle: string | null + /** Display name. */ + name: string +} + +export type Visibility = 'public' | 'private' + +export interface PublicKeyInfo { + id: string + alg: 'Ed25519' + /** Raw 32-byte public key, base64url. */ + publicKey: string +} + +const b64url = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +const fromB64url = (s: string) => + Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)) +const enc = new TextEncoder() + +export const entryHash = (e: LogEntry) => sha256Hex(jcs(e)) + +/** The bytes that are signed: the entry without its signature. */ +const signedBytes = (e: Omit) => enc.encode(jcs(e)) + +export interface Signer { + keyId: string + publicKey: PublicKeyInfo + sign(bytes: Uint8Array): Promise +} + +/** A signer from a raw 32-byte Ed25519 private key (seed), base64url. */ +export async function ed25519Signer(privateKeyB64: string): Promise { + // WebCrypto imports Ed25519 private keys as PKCS#8; wrap the seed. + const seed = fromB64url(privateKeyB64) + if (seed.length !== 32) throw new Error('Ed25519 private key must be 32 bytes') + const pkcs8 = new Uint8Array([ + 0x30, + 0x2e, + 0x02, + 0x01, + 0x00, + 0x30, + 0x05, + 0x06, + 0x03, + 0x2b, + 0x65, + 0x70, + 0x04, + 0x22, + 0x04, + 0x20, + ...seed, + ]) + const key = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']) + const jwk = await crypto.subtle.exportKey('jwk', key) + const publicKey = b64url(fromB64url(jwk.x!)) + const id = keyIdOf(publicKey) + return { + keyId: id, + publicKey: { id, alg: 'Ed25519', publicKey }, + sign: async (bytes) => + new Uint8Array(await crypto.subtle.sign('Ed25519', key, bytes as Uint8Array)), + } +} + +/** Generate a new signing key; returns the private key seed (base64url) to store as a secret. */ +export async function generateSigningKey(): Promise { + const pair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair + const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey) + return jwk.d! +} + +export async function signEntry(signer: Signer, e: UnsignedEntry): Promise { + const unsigned = { ...e, keyId: signer.keyId } + const sig = b64url(await signer.sign(signedBytes(unsigned))) + return { ...unsigned, sig } +} + +/** A key's id: the first 16 hex characters of sha256(raw public key). */ +export const keyIdOf = (publicKey: string) => sha256Hex(fromB64url(publicKey)).slice(0, 16) + +/** + * Does a trusted key sign this entry? A key counts only under its own id, so a + * key list read from a location can't file a stranger's key under a trusted id. + */ +export async function verifyEntry(e: LogEntry, keys: PublicKeyInfo[]): Promise { + const k = keys.find((x) => x.id === e.keyId) + if (!k || k.alg !== 'Ed25519' || keyIdOf(k.publicKey) !== k.id) return false + const pub = await crypto.subtle.importKey( + 'raw', + fromB64url(k.publicKey) as Uint8Array, + { name: 'Ed25519' }, + false, + ['verify'], + ) + const { sig, ...unsigned } = e + return crypto.subtle.verify( + 'Ed25519', + pub, + fromB64url(sig) as Uint8Array, + signedBytes(unsigned) as Uint8Array, + ) +} + +// --- Reading and writing the log in a repository --- + +const dec = new TextDecoder() + +async function readJson(repo: Repo, key: string): Promise { + const obj = await repo.blobs.get(key) + return obj ? (JSON.parse(dec.decode(await obj.bytes())) as T) : null +} + +export const readHead = (repo: Repo, collectionId: string) => + readJson(repo, keys.head(collectionId)) +export const readCollectionInfo = (repo: Repo, collectionId: string) => + readJson(repo, keys.collection(collectionId)) +export const readLogEntry = (repo: Repo, collectionId: string, seq: number) => + readJson(repo, keys.logEntry(collectionId, seq)) + +export async function writeCollectionInfo(repo: Repo, info: CollectionInfo): Promise { + await repo.blobs.put(keys.collection(info.id), JSON.stringify(info, null, 1), { + contentType: 'application/json', + }) +} + +/** + * Append a log entry and then move the head. Call only after every object the + * version reaches (nodes, bodies, root, private set) is written: a reader that + * finds head.json can always read everything below it. + */ +export async function appendLog(repo: Repo, collectionId: string, entry: LogEntry): Promise { + await repo.blobs.put(keys.logEntry(collectionId, entry.seq), jcs(entry), { + contentType: 'application/json', + ifAbsent: true, + }) + const head: Head = { seq: entry.seq, entryHash: entryHash(entry), versionHash: entry.versionHash } + await repo.blobs.put(keys.head(collectionId), jcs(head), { contentType: 'application/json' }) + return head +} + +/** + * Check log entries that continue collection `collectionId`'s log: consecutive + * seqs from `after.seq + 1` (or 1), each naming that collection, chaining to the + * previous entry's hash and signed by a trusted key. Returns the new head. For + * mirrors and clients that already verified the log up to `after`. + */ +export async function verifyLogEntries( + entries: readonly LogEntry[], + trustedKeys: PublicKeyInfo[], + after: { seq: number; entryHash: string } | null, + collectionId: string, +): Promise<{ seq: number; entryHash: string } | null> { + let seq = after?.seq ?? 0 + let prev = after?.entryHash ?? null + for (const e of entries) { + seq++ + if (e.seq !== seq) throw new IntegrityError(`Log entry ${seq} has seq ${e.seq}`) + if (e.collectionId !== collectionId) { + throw new IntegrityError( + `Log entry ${seq} names collection ${String(e.collectionId)}, not ${collectionId}`, + ) + } + if (e.prev !== prev) throw new IntegrityError(`Log entry ${seq} does not chain to ${seq - 1}`) + if (!(await verifyEntry(e, trustedKeys))) + throw new IntegrityError(`Log entry ${seq} has a bad signature`) + prev = entryHash(e) + } + return prev === null ? null : { seq, entryHash: prev } +} + +/** + * Check a collection's whole log: signatures, the hash chain, and that the head + * matches the last entry. O(versions); for clones and audits. + */ +export async function verifyLog( + repo: Repo, + collectionId: string, + trustedKeys: PublicKeyInfo[], +): Promise<{ head: Head; entries: LogEntry[] }> { + const head = await readHead(repo, collectionId) + if (!head) throw new IntegrityError(`Collection ${collectionId} has no head`) + const entries: LogEntry[] = [] + for (let seq = 1; seq <= head.seq; seq++) { + const e = await readLogEntry(repo, collectionId, seq) + if (!e) throw new IntegrityError(`Log entry ${seq} is missing`) + entries.push(e) + } + const last = await verifyLogEntries(entries, trustedKeys, null, collectionId) + if (last?.entryHash !== head.entryHash || entries.at(-1)?.versionHash !== head.versionHash) + throw new IntegrityError('head.json does not match the last log entry') + return { head, entries } +} diff --git a/packages/protocol/src/repo/lru.ts b/packages/protocol/src/repo/lru.ts new file mode 100644 index 0000000..0adc503 --- /dev/null +++ b/packages/protocol/src/repo/lru.ts @@ -0,0 +1,43 @@ +/** + * A byte-budgeted LRU for the isolate (or process). Holds immutable, hash-keyed + * things — roots, interior nodes, decoded leaves — so a busy collection's upper + * tree stays in memory across requests. + */ +export class Lru { + readonly #map = new Map() + #bytes = 0 + + constructor( + readonly budget: number, + readonly sizeOf: (v: V) => number, + ) {} + + get(key: string): V | undefined { + const hit = this.#map.get(key) + if (!hit) return undefined + this.#map.delete(key) + this.#map.set(key, hit) + return hit.value + } + + set(key: string, value: V): void { + const size = this.sizeOf(value) + if (size > this.budget / 4) return // never let one item flush the cache + const old = this.#map.get(key) + if (old) { + this.#bytes -= old.size + this.#map.delete(key) + } + this.#map.set(key, { value, size }) + this.#bytes += size + while (this.#bytes > this.budget) { + const oldest = this.#map.keys().next().value as string + this.#bytes -= this.#map.get(oldest)!.size + this.#map.delete(oldest) + } + } + + get bytes(): number { + return this.#bytes + } +} diff --git a/packages/protocol/src/repo/repo.ts b/packages/protocol/src/repo/repo.ts new file mode 100644 index 0000000..b0befaa --- /dev/null +++ b/packages/protocol/src/repo/repo.ts @@ -0,0 +1,455 @@ +/** + * A repository: one storage location's objects, in the documented layout + * (docs/protocol-v2.md, "Repository layout"), read and written through a + * Store. The platform primary, customer mirrors and clones all use this. + * + * nodes/ tree node JSON, gzip (hash of the uncompressed bytes) + * bodies/.ndjson.gz the leaf's records, one canonical record per line, in + * entry order; one or more concatenated gzip members + * records/.json.gz an out-of-line record; its body line is {"$ref":""} + * schemas/.json canonical JSON + * roots/.json version roots (the hex after "ulv2:") + * private/.json private set objects (public+private locations only) + * files/ file bytes + * collections//collection.json collection description and signing keys + * collections//log/.json signed version log entries + * collections//head.json the latest entry + * + * Content-addressed objects are immutable and cached forever: the isolate LRU, + * then the shared cache, then the bucket. Objects read from a location we don't + * operate (`trusted: false`) are hash-verified before they are used or cached. + * How a body is split into gzip members, and which records go out of line, are + * writer choices; readers handle any of them. + */ +import { + type DecodedNode, + decodeNode, + hashSchema, + jcs, + type NodeDesc, + type NodeSource, + type PrivateSetObject, + privateCommitment, + type RecordEntry, + recordTree, + sha256Hex, + type TreeSink, + type TreeSpec, + checkProtocolVersion, + utf8ByteLength, + type VersionRoot, + versionDigest, + versionHash, +} from '../format.js' +import { gunzipText, gzip, splitLines } from './gzip.js' +import { Lru } from './lru.js' +import { type Store, type Cache, noCache } from './types.js' + +/** Records over this size are stored once under records/ (writer policy, not protocol). */ +export const OUT_OF_LINE_BYTES = 64 * 1024 +/** Raw bytes per gzip member when a leaf body is written in pieces (writer policy). */ +export const BODY_MEMBER_BYTES = 4 * 1024 * 1024 + +export const keys = { + node: (h: string) => `nodes/${h}`, + body: (leafHash: string) => `bodies/${leafHash}.ndjson.gz`, + record: (h: string) => `records/${h}.json.gz`, + schema: (h: string) => `schemas/${h}.json`, + root: (versionHashOrDigest: string) => + `roots/${versionHashOrDigest.includes(':') ? versionDigest(versionHashOrDigest) : versionHashOrDigest}.json`, + privateSet: (commitment: string) => `private/${commitment}.json`, + file: (h: string) => `files/${h}`, + collection: (id: string) => `collections/${id}/collection.json`, + logEntry: (id: string, seq: number) => `collections/${id}/log/${seq}.json`, + head: (id: string) => `collections/${id}/head.json`, +} + +const REF_PREFIX = '{"$ref":"' +export const outOfLinePointer = (recordHash: string) => `${REF_PREFIX}${recordHash}"}` +/** The record hash a body line points to, or null for an inline record. */ +export const outOfLineHash = (line: string): string | null => + line.startsWith(REF_PREFIX) ? (JSON.parse(line) as { $ref: string }).$ref : null + +const dec = new TextDecoder() + +const sizeOf = (v: unknown) => { + if (typeof v === 'string') return v.length * 2 + if (v && typeof v === 'object' && 'approxBytes' in v) + return (v as { approxBytes: number }).approxBytes + return 1024 +} + +/** A memory cache for decoded nodes and bodies, of about `bytes`. */ +export function repoLru(bytes: number): Lru { + return new Lru(bytes, sizeOf) +} + +let shared: Lru | undefined +/** + * One memory cache for every repository in the process (a server isolate), + * created on first use. Repositories get a small private cache unless they ask + * for this one. + */ +export function sharedLru(bytes = 32 * 1024 * 1024): Lru { + return (shared ??= repoLru(bytes)) +} + +const DEFAULT_MEMORY_BYTES = 8 * 1024 * 1024 + +export class IntegrityError extends Error { + constructor(message: string) { + super(message) + this.name = 'IntegrityError' + } +} + +export interface RepoOptions { + /** A shared cache for immutable bytes (the Cache API on Workers). None by default. */ + cache?: Cache + /** Namespaces cache and LRU keys when several locations share them (the location id). */ + scope?: string + /** + * True only for locations you operate. Otherwise (the default) every object is + * hash-verified before it's used or cached. + */ + trusted?: boolean + /** The memory cache to use, e.g. `sharedLru()`. */ + lru?: Lru + /** Size of a private memory cache when `lru` isn't given (default 8 MB). */ + memoryBytes?: number +} + +/** Open the repository in a store. */ +export function openRepo(store: Store, opts: RepoOptions = {}): Repo { + return new Repo(store, opts) +} + +export class Repo { + readonly cache: Cache + readonly lru: Lru + readonly scope: string + readonly trusted: boolean + + constructor( + readonly blobs: Store, + opts: RepoOptions = {}, + ) { + this.cache = opts.cache ?? noCache + this.lru = opts.lru ?? repoLru(opts.memoryBytes ?? DEFAULT_MEMORY_BYTES) + this.scope = opts.scope ?? '' + this.trusted = opts.trusted ?? false + } + + #ck(key: string) { + return `${this.scope}/${key}` + } + + /** An immutable object's bytes: shared cache, then the bucket. `verify` runs before caching. */ + async #immutable( + key: string, + verify?: (bytes: Uint8Array) => Promise, + ): Promise { + const cached = await this.cache.get(this.#ck(key)) + if (cached) return cached + const obj = await this.blobs.get(key) + if (!obj) return null + const bytes = await obj.bytes() + if (verify && !this.trusted) await verify(bytes) + await this.cache.put(this.#ck(key), bytes) + return bytes + } + + // --- Nodes --- + + async nodeJson(hash: string): Promise { + const key = keys.node(hash) + const hit = this.lru.get(this.#ck(key)) + if (typeof hit === 'string') return hit + const bytes = await this.#immutable(key, async (b) => { + if (sha256Hex(await gunzipText(b)) !== hash) + throw new IntegrityError(`Node ${hash} fails its hash`) + }) + if (!bytes) throw new Error(`Missing node ${hash}`) + const json = await gunzipText(bytes) + this.lru.set(this.#ck(key), json) + return json + } + + async putNode(hash: string, json: string): Promise { + await this.blobs.put(keys.node(hash), await gzip(json), { + contentType: 'application/gzip', + ifAbsent: true, + }) + } + + async decoded(spec: TreeSpec, hash: string): Promise> { + const key = this.#ck(`decoded:${spec.name}:${hash}`) + const hit = this.lru.get(key) + if (hit) return (hit as { node: DecodedNode }).node + const json = await this.nodeJson(hash) + // decodeNode checks the hash and the canonical encoding. + const node = decodeNode(spec, json, hash) + this.lru.set(key, { node, approxBytes: json.length * 3 }) + return node + } + + // --- Bodies --- + + /** + * A leaf's body lines, in entry order, with out-of-line records resolved. On an + * untrusted location, each line is checked against the leaf's record hashes. + */ + async bodyLines(leaf: { hash: string; entries: readonly RecordEntry[] }): Promise { + const key = keys.body(leaf.hash) + const hit = this.lru.get(this.#ck(key)) + if (hit) return (hit as { lines: string[] }).lines + const check = async (b: Uint8Array) => { + const lines = await this.#resolve(splitLines(await gunzipText(b))) + if (lines.length !== leaf.entries.length) + throw new IntegrityError(`Body of ${leaf.hash}: wrong line count`) + lines.forEach((l, i) => { + const e = leaf.entries[i]! + if (sha256Hex(l) !== e.hash) + throw new IntegrityError(`Body of ${leaf.hash}: line ${i} fails its hash`) + // The line must also be the record the entry names: its size and its id. + if (utf8ByteLength(l) !== e.size || !l.startsWith(`{"id":${JSON.stringify(e.key)},"type":`)) + throw new IntegrityError(`Body of ${leaf.hash}: line ${i} isn't its entry's record`) + }) + } + const bytes = await this.#immutable(key, check) + if (!bytes) throw new Error(`Missing body for leaf ${leaf.hash}`) + const lines = await this.#resolve(splitLines(await gunzipText(bytes))) + if (lines.length !== leaf.entries.length) { + throw new IntegrityError( + `Body of ${leaf.hash} has ${lines.length} lines for ${leaf.entries.length} entries`, + ) + } + this.lru.set(this.#ck(key), { lines, approxBytes: lines.reduce((n, l) => n + l.length * 2, 0) }) + return lines + } + + /** + * A leaf body's stored bytes (gzip members), as written: for serving a run of + * leaves without decoding them. Pointer lines (out-of-line records) stay + * pointers; callers that need full records use bodyLines. + */ + async rawBody(leafHash: string): Promise { + const bytes = await this.#immutable(keys.body(leafHash)) + if (!bytes) throw new Error(`Missing body for leaf ${leafHash}`) + return bytes + } + + async #resolve(lines: string[]): Promise { + if (!lines.some((l) => l.startsWith(REF_PREFIX))) return lines + return Promise.all(lines.map((l) => (l.startsWith(REF_PREFIX) ? this.#outOfLine(l) : l))) + } + + /** An out-of-line record's canonical JSON, checked against its hash. */ + outOfLineRecord(recordHash: string): Promise { + return this.#outOfLine(outOfLinePointer(recordHash)) + } + + async #outOfLine(pointer: string): Promise { + const hash = (JSON.parse(pointer) as { $ref: string }).$ref + const b = await this.#immutable(keys.record(hash), async (bytes) => { + if (sha256Hex(await gunzipText(bytes)) !== hash) + throw new IntegrityError(`Record ${hash} fails its hash`) + }) + if (!b) throw new Error(`Missing out-of-line record ${hash}`) + return gunzipText(b) + } + + /** Write a body from its gzip members (already compressed, in order). */ + async putBody(leafHash: string, members: readonly Uint8Array[]): Promise { + const total = members.reduce((n, m) => n + m.byteLength, 0) + const bytes = new Uint8Array(total) + let off = 0 + for (const m of members) { + bytes.set(m, off) + off += m.byteLength + } + await this.blobs.put(keys.body(leafHash), bytes, { + contentType: 'application/gzip', + ifAbsent: true, + }) + } + + /** Store a large record out of line; returns the pointer line that goes in the body. */ + async putOutOfLine(recordHash: string, canonical: string): Promise { + await this.blobs.put(keys.record(recordHash), await gzip(canonical), { + contentType: 'application/gzip', + ifAbsent: true, + }) + return outOfLinePointer(recordHash) + } + + // --- Roots, private sets, schemas --- + + async #json(key: string, verify: (text: string, value: T) => void): Promise { + const hit = this.lru.get(this.#ck(key)) + if (hit) return (hit as { value: T }).value + const bytes = await this.#immutable(key, async (b) => { + const text = dec.decode(b) + verify(text, JSON.parse(text) as T) + }) + if (!bytes) throw new Error(`Missing ${key}`) + const text = dec.decode(bytes) + const value = JSON.parse(text) as T + this.lru.set(this.#ck(key), { value, approxBytes: text.length * 3 }) + return value + } + + /** A version root. Throws UnsupportedProtocolError for a protocol version this package can't read. */ + async root(hash: string): Promise { + const root = await this.#json(keys.root(hash), (_t, r) => { + // The version first: a later protocol hashes roots differently. + checkProtocolVersion(r) + if (versionHash(r) !== hash) throw new IntegrityError(`Root ${hash} fails its hash`) + }) + checkProtocolVersion(root) + return root + } + + async putRoot(root: VersionRoot): Promise { + const hash = versionHash(root) + await this.blobs.put(keys.root(hash), jcs(root), { + contentType: 'application/json', + ifAbsent: true, + }) + return hash + } + + privateSet(commitment: string): Promise { + return this.#json(keys.privateSet(commitment), (_t, set) => { + if (privateCommitment(set) !== commitment) + throw new IntegrityError(`Private set ${commitment} fails its hash`) + }) + } + + async putPrivateSet(set: PrivateSetObject): Promise { + const commitment = privateCommitment(set) + await this.blobs.put(keys.privateSet(commitment), jcs(set), { + contentType: 'application/json', + ifAbsent: true, + }) + return commitment + } + + schema(hash: string): Promise> { + return this.#json>(keys.schema(hash), (_t, s) => { + if (hashSchema(s) !== hash) throw new IntegrityError(`Schema ${hash} fails its hash`) + }) + } + + async putSchema(schema: unknown): Promise { + const hash = hashSchema(schema) + await this.blobs.put(keys.schema(hash), jcs(schema), { + contentType: 'application/json', + ifAbsent: true, + }) + return hash + } +} + +/** NodeSource over a repository. Record trees get bodies through `leafEntries`. */ +export class RepoSource implements NodeSource { + constructor( + readonly spec: TreeSpec, + readonly repo: Repo, + ) {} + + node(hash: string): Promise> { + return this.repo.decoded(this.spec, hash) + } + + async leafEntries(hash: string): Promise { + const node = await this.node(hash) + if (node.kind !== 'leaf') throw new Error(`Node ${hash} is not a leaf`) + if (this.spec !== (recordTree as TreeSpec)) return node.entries.slice() + const entries = node.entries as RecordEntry[] + const lines = await this.repo.bodyLines({ hash, entries }) + return entries.map((e, i) => ({ ...e, body: lines[i]! })) as E[] + } +} + +/** + * TreeSink that writes nodes (and, for record trees, bodies) to a repository. + * The builder is synchronous; writes run in the background with bounded + * concurrency. Call `drain()` between units of work and `flush()` before using + * the root. `written` lists every key written, in order, which is the commit's + * sync work for mirrors. + * + * A body is written once its leaf ends (its key is the leaf hash). Until then, + * spilled entries are held as compressed gzip members, so a large leaf costs its + * compressed size in memory rather than its raw size. + */ +export class RepoSink implements TreeSink { + readonly #pending = new Set>() + #error: unknown = null + #members: Promise[] = [] + readonly written: string[] = [] + + constructor( + readonly repo: Repo, + readonly opts: { bodyOf?: (e: E) => string; concurrency?: number } = {}, + ) {} + + #run(key: string, work: () => Promise) { + const p = work() + .catch((err) => { + this.#error ??= err + }) + .finally(() => this.#pending.delete(p)) + this.#pending.add(p) + this.written.push(key) + } + + #member(entries: readonly E[]): Promise { + const p = gzip(entries.map(this.opts.bodyOf!).join('\n') + '\n') + p.catch(() => {}) // surfaced when the body is written + return p + } + + spill(entries: readonly E[]): void { + if (this.opts.bodyOf) this.#members.push(this.#member(entries)) + } + + leaf(desc: NodeDesc, json: string, entries: readonly E[], spilled: number): void { + this.#run(keys.node(desc.hash), () => this.repo.putNode(desc.hash, json)) + if (!this.opts.bodyOf) return + const tail = entries.slice(spilled) + const members = [...this.#members, ...(tail.length > 0 ? [this.#member(tail)] : [])] + this.#members = [] + this.#run(keys.body(desc.hash), async () => + this.repo.putBody(desc.hash, await Promise.all(members)), + ) + } + + interior(desc: NodeDesc, json: string): void { + this.#run(keys.node(desc.hash), () => this.repo.putNode(desc.hash, json)) + } + + async drain(): Promise { + const limit = this.opts.concurrency ?? 16 + while (this.#pending.size >= limit) await Promise.race(this.#pending) + if (this.#error) throw this.#error + } + + async flush(): Promise { + while (this.#pending.size > 0) await Promise.race(this.#pending) + if (this.#error) throw this.#error + } +} + +export const bodyOfRecord = (e: RecordEntry): string => { + if (e.body === undefined) throw new Error(`Record ${e.key} has no body`) + return e.body +} + +/** Payload size for spilling: the body line's length. */ +export const recordPayloadBytes = (e: RecordEntry) => e.body?.length ?? 0 + +/** Clear a record entry's body once it is in a compressed member. */ +export const dropRecordBody = (e: RecordEntry) => { + delete e.body +} diff --git a/packages/protocol/src/repo/sync.ts b/packages/protocol/src/repo/sync.ts new file mode 100644 index 0000000..90264dd --- /dev/null +++ b/packages/protocol/src/repo/sync.ts @@ -0,0 +1,488 @@ +/** + * Tree sync: moving a version between repositories (edge-redesign-build.md, + * "Tree sync"). Pull-only. The sender packs the objects a version reaches that + * the receiver's base doesn't; the receiver checks every object against its key + * before writing it, then proves the new trees canonical by re-deriving them + * from the base, so a pack can't make it accept a tree the protocol wouldn't + * build. Cost on both sides is O(changes × height). + * + * A pack is a sequence of repository objects under their keys (on the wire, a + * tar; see tar.ts). Order: schemas, then each tree's new nodes top down with + * each new leaf's out-of-line records and body right after it, then the private + * set object, then the root. + */ +import { + type Change, + checkPrivateSetObject, + checkProtocolVersion, + checkVersionRoot, + diffTrees, + fileTree, + hashSchema, + jcs, + mergeTree, + type MergeStats, + newNodes, + NodeFormatError, + type NodeSource, + privateCommitment, + type PrivateSetObject, + recordCanonical, + type RecordEntry, + recordTree, + type SetObject, + sha256Hex, + type TreeSpec, + type TreeSummary, + utf8ByteLength, + type VersionRoot, + versionDigest, + versionHash, +} from '../format.js' +import { gunzipText, splitLines } from './gzip.js' +import { IntegrityError, keys, outOfLineHash, type Repo } from './repo.js' + +/** Which sets a sync moves: `public`, or `all` (public and private). */ +export type SyncSets = 'public' | 'all' + +/** One repository object in a pack. */ +export interface PackObject { + key: string + bytes: Uint8Array +} + +export interface PackOptions { + /** A version the receiver already has; null or absent packs everything. */ + base?: string | null + sets?: SyncSets +} + +async function raw(repo: Repo, key: string): Promise { + const obj = await repo.blobs.get(key) + if (!obj) throw new Error(`Missing object ${key}`) + return obj.bytes() +} + +/** A NodeSource over a repository that never reads bodies. */ +const nodesOnly = (repo: Repo, spec: TreeSpec): NodeSource => ({ + spec, + node: (hash) => repo.decoded(spec, hash), + async leafEntries(hash) { + const n = await repo.decoded(spec, hash) + if (n.kind !== 'leaf') throw new Error(`Node ${hash} is not a leaf`) + return n.entries.slice() + }, +}) + +interface SetPair { + name: 'public' | 'private' + set: SetObject + base: SetObject | null + /** The base's other set, where a type that changed sets used to live. */ + other: SetObject | null +} + +async function setPairs( + repo: Repo, + root: VersionRoot, + base: VersionRoot | null, + sets: SyncSets, +): Promise<{ pairs: SetPair[]; privateSet: PrivateSetObject | null }> { + const basePrivate = sets === 'all' && base?.private ? await repo.privateSet(base.private) : null + const pairs: SetPair[] = [ + { name: 'public', set: root.public, base: base?.public ?? null, other: basePrivate }, + ] + let privateSet: PrivateSetObject | null = null + if (sets === 'all' && root.private) { + privateSet = await repo.privateSet(root.private) + pairs.push({ + name: 'private', + set: privateSet, + base: basePrivate, + other: base?.public ?? null, + }) + } + return { pairs, privateSet } +} + +const baseTreeOf = (p: SetPair, slug: string) => + p.base?.types[slug]?.root ?? p.other?.types[slug]?.root ?? null + +/** One tree of a version's sync work: a set's record tree of one type, or its file tree. */ +export interface SyncTree { + set: 'public' | 'private' + kind: 'records' | 'files' + slug: string | null + base: string | null + target: string | null +} + +/** Everything a version's sync moves, in the order it moves it. */ +export interface VersionWork { + root: VersionRoot + /** Keys of the schemas the base lacks. */ + schemas: string[] + trees: SyncTree[] + /** The private set object's key, when the private set is sent. */ + privateSet: string | null + /** The root's key: written last. */ + rootKey: string +} + +/** Plan what syncing version `target` over `opts.base` moves. */ +export async function versionWork( + repo: Repo, + target: string, + opts: PackOptions = {}, +): Promise { + const sets = opts.sets ?? 'public' + const root = await repo.root(target) + const base = opts.base ? await repo.root(opts.base) : null + const { pairs, privateSet } = await setPairs(repo, root, base, sets) + const known = new Set() + for (const s of [base?.public, ...pairs.map((p) => p.base)]) { + for (const t of Object.values(s?.types ?? {})) known.add(t.schema) + } + const schemas: string[] = [] + const trees: SyncTree[] = [] + for (const p of pairs) { + for (const t of Object.values(p.set.types)) { + if (known.has(t.schema)) continue + known.add(t.schema) + schemas.push(keys.schema(t.schema)) + } + } + for (const p of pairs) { + for (const [slug, t] of Object.entries(p.set.types)) { + trees.push({ set: p.name, kind: 'records', slug, base: baseTreeOf(p, slug), target: t.root }) + } + trees.push({ + set: p.name, + kind: 'files', + slug: null, + base: p.base?.files.root ?? null, + target: p.set.files.root, + }) + } + return { + root, + schemas, + trees, + privateSet: privateSet ? keys.privateSet(root.private!) : null, + rootKey: keys.root(target), + } +} + +/** + * One tree's new objects, top down: nodes, and after each record leaf its + * out-of-line records and body. `resumeKey` marks the objects after which a + * walk can stop and resume with `after` (each leaf's last key). + */ +export async function* treeObjects( + repo: Repo, + tree: SyncTree, + after: string | null = null, +): AsyncGenerator { + const source = tree.kind === 'records' ? nodesOnly(repo, recordTree) : nodesOnly(repo, fileTree) + for await (const n of newNodes(source as NodeSource, tree.base, tree.target, { + after, + })) { + const node = { key: keys.node(n.hash), bytes: await raw(repo, keys.node(n.hash)) } + if (n.level !== 0) { + yield node + continue + } + if (tree.kind === 'files') { + yield { ...node, resumeKey: n.lastKey } + continue + } + yield node + const body = await raw(repo, keys.body(n.hash)) + for (const line of splitLines(await gunzipText(body))) { + const h = outOfLineHash(line) + if (h) yield { key: keys.record(h), bytes: await raw(repo, keys.record(h)) } + } + yield { key: keys.body(n.hash), bytes: body, resumeKey: n.lastKey } + } +} + +/** The objects version `target` reaches that `opts.base` doesn't. */ +export async function* packVersion( + repo: Repo, + target: string, + opts: PackOptions = {}, +): AsyncGenerator { + const work = await versionWork(repo, target, opts) + for (const key of work.schemas) yield { key, bytes: await raw(repo, key) } + for (const tree of work.trees) { + for await (const { key, bytes } of treeObjects(repo, tree)) yield { key, bytes } + } + if (work.privateSet) yield { key: work.privateSet, bytes: await raw(repo, work.privateSet) } + yield { key: work.rootKey, bytes: await raw(repo, work.rootKey) } +} + +export interface ReceiveOptions { + /** The version being received. */ + target: string + /** The local version the pack was made against (already verified here). */ + base?: string | null + sets?: SyncSets + /** + * Refuse a body that keeps a record of this many bytes or fewer out of line. + * Which records go out of line is the writer's choice; a receiver whose own + * bookkeeping relies on its policy (storage cleanup marks out-of-line records + * by size) holds incoming data to it. Default: any. + */ + inlineUpTo?: number +} + +export interface ReceiveResult { + root: VersionRoot + objects: number + bytes: number + /** Record changes from the base, over the sets received. */ + changes: { added: number; removed: number; updated: number } +} + +const HEX = '([0-9a-f]{64})' +const KEY = { + node: new RegExp(`^nodes/${HEX}$`), + record: new RegExp(`^records/${HEX}\\.json\\.gz$`), + body: new RegExp(`^bodies/${HEX}\\.ndjson\\.gz$`), + schema: new RegExp(`^schemas/${HEX}\\.json$`), + privateSet: new RegExp(`^private/${HEX}\\.json$`), + root: new RegExp(`^roots/${HEX}\\.json$`), +} +const dec = new TextDecoder() + +/** Parse canonical JSON: the bytes must be exactly the JCS of what they parse to. */ +function canonical(key: string, bytes: Uint8Array): T { + const text = dec.decode(bytes) + const value = JSON.parse(text) as T + if (jcs(value) !== text) throw new IntegrityError(`${key} is not canonical JSON`) + return value +} + +/** + * Receive a pack into `repo`: verify and write each object, then check that the + * version's trees are exactly what the protocol builds. Throws IntegrityError + * (and leaves the version unwritten) on any mismatch. + */ +export async function receiveVersion( + repo: Repo, + objects: AsyncIterable | Iterable, + opts: ReceiveOptions, +): Promise { + const digest = versionDigest(opts.target) + let rootBytes: Uint8Array | null = null + let count = 0 + let total = 0 + const put = (key: string, bytes: Uint8Array, contentType: string) => + repo.blobs.put(key, bytes, { contentType, ifAbsent: true }) + + // The type of each received leaf's records, checked against its tree's type below. + const leafTypes = new Map() + for await (const { key, bytes } of objects) { + count++ + total += bytes.byteLength + try { + rootBytes = + (await receiveObject(repo, key, bytes, opts.target, digest, leafTypes, opts.inlineUpTo)) ?? + rootBytes + } catch (err) { + if (err instanceof IntegrityError) throw err + // Undecodable gzip or JSON is a bad object too. + throw new IntegrityError(`${key}: ${(err as Error).message}`) + } + } + if (!rootBytes) throw new IntegrityError(`The pack has no root for ${opts.target}`) + + const root = JSON.parse(dec.decode(rootBytes)) as VersionRoot + const base = opts.base ? await repo.root(opts.base) : null + const { pairs, privateSet } = await setPairs(repo, root, base, opts.sets ?? 'public') + // Checked on arrival too; this covers a private set already held here. + const privateError = privateSet && checkPrivateSetObject(privateSet) + if (privateError) throw new IntegrityError(`Private set ${root.private}: ${privateError}`) + const changes = { added: 0, removed: 0, updated: 0 } + for (const p of pairs) { + for (const [slug, t] of Object.entries(p.set.types)) { + await repo.schema(t.schema) + const s = await checkTree(repo, recordTree, baseTreeOf(p, slug), t, `${p.name} ${slug}`, { + slug, + leafTypes, + }) + changes.added += s.added + changes.removed += s.removed + changes.updated += s.updated + } + await checkTree(repo, fileTree, p.base?.files.root ?? null, p.set.files, `${p.name} files`) + } + // Last: a reader that finds the root can read everything below it. + await put(keys.root(opts.target), rootBytes, 'application/json') + return { root, objects: count, bytes: total, changes } +} + +/** Verify one pack object and write it; the root's bytes are returned, not written. */ +async function receiveObject( + repo: Repo, + key: string, + bytes: Uint8Array, + target: string, + digest: string, + leafTypes: Map, + inlineUpTo?: number, +): Promise { + const put = (k: string, b: Uint8Array, contentType: string) => + repo.blobs.put(k, b, { contentType, ifAbsent: true }) + let m: RegExpExecArray | null + if ((m = KEY.node.exec(key))) { + if (sha256Hex(await gunzipText(bytes)) !== m[1]) + throw new IntegrityError(`${key} fails its hash`) + await put(key, bytes, 'application/gzip') + } else if ((m = KEY.record.exec(key))) { + if (sha256Hex(await gunzipText(bytes)) !== m[1]) + throw new IntegrityError(`${key} fails its hash`) + await put(key, bytes, 'application/gzip') + } else if ((m = KEY.body.exec(key))) { + // The leaf comes before its body, and its out-of-line records before it. + const leaf = await repo.decoded(recordTree, m[1]!).catch((err: Error) => { + if (err instanceof NodeFormatError) throw new IntegrityError(`${key}: ${err.message}`) + return null + }) + if (leaf?.kind !== 'leaf') throw new IntegrityError(`${key} arrived without its leaf`) + leafTypes.set(m[1]!, await checkBody(repo, key, bytes, leaf.entries, inlineUpTo)) + await put(key, bytes, 'application/gzip') + } else if ((m = KEY.schema.exec(key))) { + if (hashSchema(canonical(key, bytes)) !== m[1]) + throw new IntegrityError(`${key} fails its hash`) + await put(key, bytes, 'application/json') + } else if ((m = KEY.privateSet.exec(key))) { + const set = canonical(key, bytes) + if (privateCommitment(set) !== m[1]) throw new IntegrityError(`${key} fails its hash`) + const shape = checkPrivateSetObject(set) + if (shape) throw new IntegrityError(`${key}: ${shape}`) + await put(key, bytes, 'application/json') + } else if ((m = KEY.root.exec(key))) { + if (m[1] !== digest) throw new IntegrityError(`The pack holds another version's root (${key})`) + const r = canonical(key, bytes) + checkProtocolVersion(r) + if (versionHash(r) !== target) throw new IntegrityError(`${key} fails its hash`) + const shape = checkVersionRoot(r) + if (shape) throw new IntegrityError(`${key}: ${shape}`) + return bytes + } else { + throw new IntegrityError(`A pack can't hold ${JSON.stringify(key)}`) + } + return null +} + +/** + * A body's lines, out-of-line records resolved, must be the records the leaf's + * entries name (docs/protocol-v2.md §8.3). Returns the records' type. + */ +async function checkBody( + repo: Repo, + key: string, + bytes: Uint8Array, + entries: readonly RecordEntry[], + inlineUpTo?: number, +): Promise { + const lines = splitLines(await gunzipText(bytes)) + if (lines.length !== entries.length) throw new IntegrityError(`${key}: wrong line count`) + let type: string | null = null + for (let i = 0; i < lines.length; i++) { + const ref = outOfLineHash(lines[i]!) + let line = lines[i]! + if (ref) { + if (inlineUpTo !== undefined && entries[i]!.size <= inlineUpTo) + throw new IntegrityError( + `${key}: line ${i} keeps a ${entries[i]!.size}-byte record out of line; this repository keeps records up to ${inlineUpTo} bytes inline`, + ) + const rec = await repo.blobs.get(keys.record(ref)) + if (!rec) throw new IntegrityError(`${key}: out-of-line record ${ref} is missing`) + line = await gunzipText(await rec.bytes()) + } + const t = checkRecordLine(`${key}: line ${i}`, line, entries[i]!) + if (type !== null && t !== type) + throw new IntegrityError(`${key}: holds records of more than one type`) + type = t + } + if (type === null) throw new IntegrityError(`${key}: an empty leaf`) + return type +} + +/** + * One record line against its leaf entry: it hashes to the entry's hash, its + * byte length is the entry's size, and it is the canonical record (section 4) + * with the entry's key as its id. Returns the record's type. + */ +function checkRecordLine(what: string, line: string, entry: RecordEntry): string { + if (sha256Hex(line) !== entry.hash) throw new IntegrityError(`${what} fails its hash`) + if (utf8ByteLength(line) !== entry.size) + throw new IntegrityError(`${what}: its size isn't the entry's (${entry.size})`) + let r: { id?: unknown; type?: unknown; data?: unknown } + try { + r = JSON.parse(line) as typeof r + } catch { + throw new IntegrityError(`${what} is not a canonical record`) + } + if (typeof r.id !== 'string' || typeof r.type !== 'string') + throw new IntegrityError(`${what} is not a canonical record`) + if (r.id !== entry.key) + throw new IntegrityError(`${what}: its id isn't the entry's key ${JSON.stringify(entry.key)}`) + if (recordCanonical(r.id, r.type, r.data) !== line) + throw new IntegrityError(`${what} is not a canonical record`) + return r.type +} + +/** + * A received tree must be the one the protocol builds: the changes from the base + * to it, merged into the base, give back its root, count and bytes. A record + * tree's new leaves must also have their bodies (checked as they arrived). + */ +async function checkTree( + repo: Repo, + spec: TreeSpec, + baseRoot: string | null, + want: TreeSummary, + what: string, + records?: { slug: string; leafTypes: Map }, +): Promise { + const source = nodesOnly(repo, spec) + try { + if (records) { + for await (const n of newNodes(source, baseRoot, want.root)) { + if (n.level !== 0) continue + const body = await repo.blobs.get(keys.body(n.hash)) + if (!body) throw new IntegrityError(`${what}: leaf ${n.hash} has no body`) + // A leaf already held here (not in this pack) is checked from its stored body. + let type = records.leafTypes.get(n.hash) + if (type === undefined) { + const leaf = await repo.decoded(recordTree, n.hash) + if (leaf.kind !== 'leaf') throw new IntegrityError(`${what}: ${n.hash} is not a leaf`) + type = await checkBody(repo, keys.body(n.hash), await body.bytes(), leaf.entries) + } + if (type !== records.slug) + throw new IntegrityError(`${what}: leaf ${n.hash} holds records of type ${type}`) + } + } + const changes = (async function* (): AsyncGenerator> { + for await (const d of diffTrees(source, baseRoot, want.root)) + yield { key: d.key, entry: d.after } + })() + const merged = await mergeTree(source, { leaf() {}, interior() {} }, baseRoot, changes) + const got = merged.root + if ( + (got?.hash ?? null) !== want.root || + (got?.count ?? 0) !== want.count || + (got?.bytes ?? 0) !== want.bytes + ) { + throw new IntegrityError(`${what}: the tree is not the canonical tree of its entries`) + } + return merged.stats + } catch (err) { + if (err instanceof IntegrityError) throw err + // Missing nodes, keys out of order, malformed nodes: all a bad pack. + throw new IntegrityError(`${what}: ${(err as Error).message}`) + } +} diff --git a/packages/protocol/src/repo/tar.ts b/packages/protocol/src/repo/tar.ts new file mode 100644 index 0000000..9f966bb --- /dev/null +++ b/packages/protocol/src/repo/tar.ts @@ -0,0 +1,200 @@ +/** + * POSIX tar over Web Streams, for packs (sync) and exports. The writer is + * pull-based, so a slow reader holds back storage reads instead of buffering + * the archive; each entry's size must be known before its bytes, and long names + * use a PAX extended header. The reader handles what the writer produces plus + * ustar name prefixes, and skips other entry types. + */ +const enc = new TextEncoder() +const dec = new TextDecoder() +const BLOCK = 512 + +function octal(n: number, width: number): string { + return n.toString(8).padStart(width - 1, '0') + '\0' +} + +function header(name: string, size: number, type: '0' | 'x', mtime: number): Uint8Array { + const h = new Uint8Array(BLOCK) + const put = (s: string, off: number, len: number) => h.set(enc.encode(s).subarray(0, len), off) + put(name, 0, 100) + put(octal(0o644, 8), 100, 8) + put(octal(0, 8), 108, 8) + put(octal(0, 8), 116, 8) + put(octal(size, 12), 124, 12) + put(octal(Math.floor(mtime / 1000), 12), 136, 12) + put(' ', 148, 8) // checksum placeholder + put(type, 156, 1) + put('ustar\0', 257, 6) + put('00', 263, 2) + let sum = 0 + for (const b of h) sum += b + put(octal(sum, 7) + ' ', 148, 8) + return h +} + +function pax(name: string): Uint8Array { + // " path=\n", where counts itself. + const body = (n: number) => `${n} path=${name}\n` + let len = enc.encode(body(0)).byteLength + while (enc.encode(body(len)).byteLength !== len) len = enc.encode(body(len)).byteLength + return enc.encode(body(len)) +} + +const pad = (size: number) => (size % BLOCK === 0 ? 0 : BLOCK - (size % BLOCK)) + +export interface TarEntry { + name: string + size: number + /** Produces exactly `size` bytes. */ + body: () => AsyncIterable +} + +/** The archive's bytes, entry by entry. */ +export async function* tarChunks( + entries: AsyncIterable | Iterable, + mtime = Date.now(), +): AsyncGenerator { + for await (const e of entries as AsyncIterable) { + if (enc.encode(e.name).byteLength > 100) { + const p = pax(e.name) + yield header('PaxHeader', p.byteLength, 'x', mtime) + yield p + yield new Uint8Array(pad(p.byteLength)) + } + yield header(e.name, e.size, '0', mtime) + let written = 0 + for await (const chunk of e.body()) { + written += chunk.byteLength + yield chunk + } + if (written !== e.size) + throw new Error(`tar: ${e.name} produced ${written} bytes, declared ${e.size}`) + yield new Uint8Array(pad(e.size)) + } + yield new Uint8Array(BLOCK * 2) +} + +/** A tar stream that reads its entries only as fast as it is consumed. */ +export function tarStream( + entries: AsyncIterable | Iterable, + mtime = Date.now(), +): ReadableStream { + const it = tarChunks(entries, mtime) + return new ReadableStream({ + async pull(controller) { + try { + const { value, done } = await it.next() + if (done) controller.close() + else controller.enqueue(value) + } catch (err) { + controller.error(err) + } + }, + async cancel(reason) { + await it.return?.(reason) + }, + }) +} + +/** A tar entry read back: its name and all of its bytes. */ +export interface TarFile { + name: string + bytes: Uint8Array +} + +/** Reads exactly n bytes at a time from a chunked byte stream. */ +class ByteReader { + readonly #it: AsyncIterator + #buf: Uint8Array = new Uint8Array(0) + #at = 0 + constructor(src: AsyncIterable) { + this.#it = src[Symbol.asyncIterator]() + } + /** n bytes, or null at a clean end of input. Throws on a truncated read. */ + async take(n: number): Promise { + const out = new Uint8Array(n) + let filled = 0 + while (filled < n) { + if (this.#at >= this.#buf.length) { + const { value, done } = await this.#it.next() + if (done) { + if (filled === 0) return null + throw new Error('tar: truncated archive') + } + this.#buf = value + this.#at = 0 + continue + } + const k = Math.min(n - filled, this.#buf.length - this.#at) + out.set(this.#buf.subarray(this.#at, this.#at + k), filled) + this.#at += k + filled += k + } + return out + } +} + +const str = (b: Uint8Array, off: number, len: number) => { + const s = b.subarray(off, off + len) + const end = s.indexOf(0) + return dec.decode(end === -1 ? s : s.subarray(0, end)) +} + +async function* chunks(src: ReadableStream | AsyncIterable) { + if (Symbol.asyncIterator in src) { + yield* src as AsyncIterable + return + } + const reader = (src as ReadableStream).getReader() + try { + for (;;) { + const { value, done } = await reader.read() + if (done) return + yield value + } + } finally { + reader.releaseLock() + } +} + +/** + * The regular files of a tar archive, in order. An entry over `maxEntryBytes` + * is refused rather than buffered. + */ +export async function* untar( + src: ReadableStream | AsyncIterable, + opts: { maxEntryBytes?: number } = {}, +): AsyncGenerator { + const max = opts.maxEntryBytes ?? 256 * 1024 * 1024 + const r = new ByteReader(chunks(src)) + let paxPath: string | null = null + for (;;) { + const h = await r.take(BLOCK) + if (!h) throw new Error('tar: missing end-of-archive marker') + if (h.every((b) => b === 0)) return + let sum = 0 + for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 32 : h[i]! + if (sum !== parseInt(str(h, 148, 8).trim(), 8)) throw new Error('tar: bad header checksum') + const size = parseInt(str(h, 124, 12).trim() || '0', 8) + if (!Number.isSafeInteger(size) || size < 0) throw new Error('tar: bad entry size') + if (size > max) throw new Error(`tar: entry of ${size} bytes is over the limit`) + const type = String.fromCharCode(h[156]!) + const body = (await r.take(size + pad(size))) ?? new Uint8Array(0) + const bytes = body.subarray(0, size) + if (type === 'x') { + for (const rec of dec.decode(bytes).split('\n')) { + const m = /^\d+ path=(.*)$/s.exec(rec) + if (m) paxPath = m[1]! + } + continue + } + if (type !== '0' && type !== '\0') { + paxPath = null + continue + } + const prefix = str(h, 345, 155) + const name = paxPath ?? (prefix ? `${prefix}/${str(h, 0, 100)}` : str(h, 0, 100)) + paxPath = null + yield { name, bytes } + } +} diff --git a/packages/protocol/src/repo/types.ts b/packages/protocol/src/repo/types.ts new file mode 100644 index 0000000..c04de49 --- /dev/null +++ b/packages/protocol/src/repo/types.ts @@ -0,0 +1,309 @@ +/** The storage interfaces a repository is read and written through. */ + +export interface BlobHead { + size: number + etag: string + contentType: string | null +} + +export interface BlobObject extends BlobHead { + body: ReadableStream + bytes(): Promise + text(): Promise +} + +/** What a listing knows about each key: its size and when it was last written (ms). */ +export interface ObjectInfo { + size: number + modified: number +} + +/** A page of keys; `info`, when the store gives it, is aligned with `keys`. */ +export interface ListPage { + keys: string[] + cursor?: string + info?: ObjectInfo[] +} + +export interface PutOptions { + contentType?: string + /** Cache-Control stored with the object and sent when it's served (s3, r2, memory; fs ignores it). */ + cacheControl?: string + /** Only write if the key doesn't exist. Immutable keys make this an optimization. */ + ifAbsent?: boolean +} + +export interface PutPartsOptions extends PutOptions { + /** Runs after the last part and before the object appears; throwing abandons the write. */ + check?: () => void | Promise +} + +export interface PresignGetOptions { + expiresIn: number + /** Content-Disposition for the response. */ + disposition?: string + contentType?: string +} + +export interface PresignPutOptions { + expiresIn: number + contentType?: string +} + +/** + * Where a repository's objects live: an S3-like bucket. The five methods are + * what reading, writing and mirroring need, and every store has them. + * Stores: `memoryStore()`, `fileStore(dir)`, `s3Store({…})`, `r2Store(binding)`. + * + * `list` cursors are opaque: pass back exactly what the previous page returned. + */ +export interface Store { + /** The object, or a byte range of it (`size` is then the range's size); null if absent. */ + get(key: string, range?: { offset: number; length?: number }): Promise + head(key: string): Promise + /** `ifAbsent` on an existing key succeeds without writing (keys are immutable). */ + put(key: string, body: Uint8Array | string, opts?: PutOptions): Promise + /** + * Keys under a prefix in byte order, a page at a time. The built-in stores also + * give each key's size and last write (storage cleanup ages and sizes by them). + */ + list(prefix: string, cursor?: string): Promise + /** Deleting an absent key succeeds. */ + delete(key: string): Promise + /** + * Write an object from parts, in order, without holding it whole: a multipart + * upload where the store has one. Every part but the last must be at least + * 5 MiB (S3's minimum). `putFromParts` falls back to put for stores without it. + */ + putParts?(key: string, parts: AsyncIterable, opts?: PutPartsOptions): Promise + /** + * Server-side copy, where the store has one; `copyObject` falls back to get and + * put. Given the size, a store may copy a large object in parts (S3: over 5 GiB). + */ + copy?(from: string, to: string, size?: number): Promise + /** Presigned URLs and multipart uploads, where the store can hand out URLs. */ + readonly presigner?: Presigner +} + +/** Direct client access to a store: what the platform needs for uploads and downloads. */ +export interface Presigner { + presignGet(key: string, opts: PresignGetOptions): Promise + presignPut(key: string, opts: PresignPutOptions): Promise + createMultipart(key: string, contentType?: string): Promise + presignPart(key: string, uploadId: string, partNumber: number, expiresIn: number): Promise + completeMultipart( + key: string, + uploadId: string, + parts: { partNumber: number; etag: string }[], + ): Promise + abortMultipart(key: string, uploadId: string): Promise +} + +/** A store that can presign. */ +export type PresigningStore = Store & { readonly presigner: Presigner } + +/** Copy an object within a store: natively when it can, else by reading and writing it. */ +export async function copyObject( + store: Store, + from: string, + to: string, + size?: number, +): Promise { + if (store.copy) return store.copy(from, to, size) + const obj = await store.get(from) + if (!obj) throw new Error(`copyObject: ${from} does not exist`) + await store.put(to, await obj.bytes(), obj.contentType ? { contentType: obj.contentType } : {}) +} + +/** An object read as consecutive byte ranges of `partBytes`. */ +export async function* readParts( + store: Store, + key: string, + size: number, + partBytes: number, +): AsyncGenerator { + for (let offset = 0; offset < size; offset += partBytes) { + const obj = await store.get(key, { offset, length: Math.min(partBytes, size - offset) }) + if (!obj) throw new Error(`${key} is missing`) + yield await obj.bytes() + } +} + +/** Write from parts with the store's putParts, or by joining them and putting. */ +export async function putFromParts( + store: Store, + key: string, + parts: AsyncIterable, + opts: PutPartsOptions = {}, +): Promise { + if (store.putParts) return store.putParts(key, parts, opts) + const bytes = await joinParts(parts) + await opts.check?.() + const { check: _, ...put } = opts + await store.put(key, bytes, put) +} + +/** S3's (and R2's) smallest part, except the last. */ +export const MIN_PART_BYTES = 5 * 1024 * 1024 + +/** A started multipart upload, as `writeInParts` drives it. */ +export interface MultipartWriter { + part(partNumber: number, bytes: Uint8Array): Promise<{ partNumber: number; etag: string }> + complete(parts: { partNumber: number; etag: string }[]): Promise + abort(): Promise +} + +/** + * A multipart write from parts of any size: small ones are joined up to + * MIN_PART_BYTES, and an object that fits in one part is a plain `single` put. + * `check` runs before the object appears; a failure aborts the upload. + */ +export async function writeInParts( + parts: AsyncIterable, + check: (() => void | Promise) | undefined, + w: { single(bytes: Uint8Array): Promise; begin(): Promise }, +): Promise { + const it = joinUpTo(parts, MIN_PART_BYTES)[Symbol.asyncIterator]() + let cur = await it.next() + let next = cur.done ? cur : await it.next() + if (next.done) { + await check?.() + return w.single(cur.done ? new Uint8Array(0) : cur.value) + } + const upload = await w.begin() + try { + const done: { partNumber: number; etag: string }[] = [] + while (!cur.done) { + done.push(await upload.part(done.length + 1, cur.value)) + cur = next + next = cur.done ? cur : await it.next() + } + await check?.() + await upload.complete(done) + } catch (err) { + await upload.abort().catch(() => {}) + throw err + } +} + +/** Parts of at least `min` bytes (the last may be smaller), joining small ones. */ +async function* joinUpTo( + parts: AsyncIterable, + min: number, +): AsyncGenerator { + let held: Uint8Array[] = [] + let n = 0 + for await (const p of parts) { + held.push(p) + n += p.byteLength + if (n >= min) { + yield held.length === 1 ? held[0]! : await joinParts(held) + held = [] + n = 0 + } + } + if (held.length > 0) yield held.length === 1 ? held[0]! : await joinParts(held) +} + +/** Concatenate parts (stores with no multipart write). */ +export async function joinParts( + parts: AsyncIterable | Iterable, +): Promise { + const chunks: Uint8Array[] = [] + for await (const p of parts) chunks.push(p) + const out = new Uint8Array(chunks.reduce((n, c) => n + c.byteLength, 0)) + let off = 0 + for (const c of chunks) { + out.set(c, off) + off += c.byteLength + } + return out +} + +/** Every key under a prefix, following list pages. */ +export async function* listAll(store: Store, prefix: string): AsyncGenerator { + let cursor: string | undefined + do { + const page = await store.list(prefix, cursor) + yield* page.keys + cursor = page.cursor + } while (cursor !== undefined) +} + +/** A shared cache for immutable, hash-keyed bytes (nodes, roots, schemas, bodies). */ +export interface Cache { + get(key: string): Promise + put(key: string, value: Uint8Array, opts?: { ttlSeconds?: number }): Promise +} + +/** A cache that holds nothing (tests that count blob reads; untrusted locations). */ +export const noCache: Cache = { + get: async () => null, + put: async () => {}, +} + +/** + * A store whose keys live under a prefix: one location's repository inside a + * bucket shared with other things. `prefix` has no trailing slash ('' for none). + * List cursors pass through untouched, since they're opaque to everyone but the + * inner store. + */ +export class PrefixedStore implements Store { + readonly #p: string + readonly presigner?: Presigner + readonly copy?: (from: string, to: string, size?: number) => Promise + readonly putParts?: ( + key: string, + parts: AsyncIterable, + opts?: PutPartsOptions, + ) => Promise + constructor( + readonly inner: Store, + prefix: string, + ) { + const p = prefix ? `${prefix.replace(/\/+$/, '')}/` : '' + this.#p = p + const k = (key: string) => p + key + const inn = inner.presigner + if (inn) { + this.presigner = { + presignGet: (key, opts) => inn.presignGet(k(key), opts), + presignPut: (key, opts) => inn.presignPut(k(key), opts), + createMultipart: (key, type) => inn.createMultipart(k(key), type), + presignPart: (key, id, n, exp) => inn.presignPart(k(key), id, n, exp), + completeMultipart: (key, id, parts) => inn.completeMultipart(k(key), id, parts), + abortMultipart: (key, id) => inn.abortMultipart(k(key), id), + } + } + if (inner.copy) { + const copy = inner.copy.bind(inner) + this.copy = (from, to, size) => copy(k(from), k(to), size) + } + if (inner.putParts) { + const putParts = inner.putParts.bind(inner) + this.putParts = (key, parts, opts) => putParts(k(key), parts, opts) + } + } + #k = (key: string) => this.#p + key + get(key: string, range?: { offset: number; length?: number }) { + return this.inner.get(this.#k(key), range) + } + head(key: string) { + return this.inner.head(this.#k(key)) + } + put(key: string, body: Uint8Array | string, opts?: PutOptions) { + return this.inner.put(this.#k(key), body, opts) + } + delete(key: string) { + return this.inner.delete(this.#k(key)) + } + async list(prefix: string, cursor?: string): Promise { + const r = await this.inner.list(this.#k(prefix), cursor) + const keys = r.keys.map((k) => k.slice(this.#p.length)) + return { + keys, + ...(r.cursor === undefined ? {} : { cursor: r.cursor }), + ...(r.info ? { info: r.info } : {}), + } + } +} diff --git a/packages/protocol/src/root.ts b/packages/protocol/src/root.ts new file mode 100644 index 0000000..57af79e --- /dev/null +++ b/packages/protocol/src/root.ts @@ -0,0 +1,194 @@ +/** + * Version roots, set objects and the private-set commitment. + * + * root = {"underlay":2,"metadata":{…}|null,"public":SetObject,"private":commitment|null} + * SetObject = {"types":{slug:{"schema","root","count","bytes"}},"files":{"root","count","bytes"}} + * private set object = SetObject + {"salt": 64 hex} + * commitment = sha256(JCS(private set object)) + * version hash = "ulv2:" + sha256(JCS(root)) + */ +import { PROTOCOL_VERSION, VERSION_HASH_PREFIX } from './constants.js' +import { sha256Hex } from './hash.js' +import { checkTypeSlug } from './input-rules.js' +import { jcs } from './jcs.js' + +export interface TreeSummary { + root: string | null + count: number + bytes: number +} + +export interface TypeEntry extends TreeSummary { + schema: string +} + +export interface SetObject { + types: Record + files: TreeSummary +} + +export interface PrivateSetObject extends SetObject { + salt: string +} + +export interface VersionRoot { + underlay: typeof PROTOCOL_VERSION + metadata: Record | null + public: SetObject + private: string | null +} + +export const EMPTY_TREE: TreeSummary = Object.freeze({ root: null, count: 0, bytes: 0 }) + +export function emptySet(): SetObject { + return { types: {}, files: { ...EMPTY_TREE } } +} + +/** A private set is empty when it has no types and no files; its commitment is then null. */ +export function isEmptySet(s: SetObject): boolean { + return Object.keys(s.types).length === 0 && s.files.count === 0 +} + +/** + * The protocol versions this package reads, checked against `underlay` in every + * root it loads. v1 hashes are computed for migration, but v1 never had + * repositories to read. + */ +export const SUPPORTED_PROTOCOL_VERSIONS: readonly number[] = [2] + +/** A root written in a protocol version this package can't read. */ +export class UnsupportedProtocolError extends Error { + constructor(readonly version: unknown) { + super( + `Underlay protocol version ${JSON.stringify(version)} is not supported (this package reads ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})`, + ) + this.name = 'UnsupportedProtocolError' + } +} + +/** Throw unless a root's `underlay` version is one this package reads. */ +export function checkProtocolVersion(root: { underlay?: unknown }): void { + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(root.underlay as number)) { + throw new UnsupportedProtocolError(root.underlay) + } +} + +/** 32 random bytes as hex: one per collection, reused across its versions. */ +export function newSalt(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +} + +export function privateCommitment(p: PrivateSetObject): string { + return sha256Hex(jcs(p)) +} + +export function makeRoot( + metadata: Record | null, + pub: SetObject, + priv: PrivateSetObject | null, +): VersionRoot { + return { + underlay: PROTOCOL_VERSION, + metadata, + public: pub, + private: priv && !isEmptySet(priv) ? privateCommitment(priv) : null, + } +} + +export function encodeRoot(root: VersionRoot): string { + return jcs(root) +} + +export function versionHash(root: VersionRoot): string { + return VERSION_HASH_PREFIX + sha256Hex(encodeRoot(root)) +} + +/** The bare hex digest of a `ulv2:` version hash (the object key under roots/). */ +export function versionDigest(hash: string): string { + if (!hash.startsWith(VERSION_HASH_PREFIX)) { + // `ulv:` names another protocol version: say so rather than "malformed". + const other = /^ulv(\d+):/.exec(hash) + if (other) throw new UnsupportedProtocolError(Number(other[1])) + throw new Error(`Not a v2 version hash: ${hash}`) + } + return hash.slice(VERSION_HASH_PREFIX.length) +} + +/** Totals over every type tree of a set (records only). */ +export function setRecordTotals(s: SetObject): { count: number; bytes: number } { + let count = 0 + let bytes = 0 + for (const t of Object.values(s.types)) { + count += t.count + bytes += t.bytes + } + return { count, bytes } +} + +const HEX64 = /^[0-9a-f]{64}$/ + +function checkSummary(t: unknown, where: string): string | null { + const s = t as TreeSummary + if (!s || typeof s !== 'object') return `${where}: not an object` + if (s.root !== null && (typeof s.root !== 'string' || !HEX64.test(s.root))) + return `${where}: bad root` + if (!Number.isSafeInteger(s.count) || s.count < 0) return `${where}: bad count` + if (!Number.isSafeInteger(s.bytes) || s.bytes < 0) return `${where}: bad bytes` + if ((s.root === null) !== (s.count === 0)) return `${where}: root and count disagree` + if (s.root === null && s.bytes !== 0) return `${where}: root and bytes disagree` + return null +} + +const isPlainObject = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v) + +/** Shape check for a set object read from storage or a peer. Returns an error or null. */ +export function checkSetObject(s: unknown, isPrivate = false): string | null { + const set = s as PrivateSetObject + if (!isPlainObject(set) || !isPlainObject(set.types)) return 'set: bad types' + const expected = isPrivate ? 3 : 2 + if (Object.keys(set).length !== expected) return 'set: unexpected fields' + if (isPrivate && (typeof set.salt !== 'string' || !HEX64.test(set.salt))) return 'set: bad salt' + for (const [slug, t] of Object.entries(set.types)) { + const slugError = checkTypeSlug(slug) + if (slugError) return `type ${JSON.stringify(slug)}: ${slugError}` + const e = checkSummary(t, `type ${slug}`) + if (e) return e + if (typeof t.schema !== 'string' || !HEX64.test(t.schema)) + return `type ${slug}: bad schema hash` + if (Object.keys(t).length !== 4) return `type ${slug}: unexpected fields` + } + const e = checkSummary(set.files, 'files') + if (e) return e + if (Object.keys(set.files).length !== 3) return 'files: unexpected fields' + return null +} + +const ROOT_MEMBERS = ['underlay', 'metadata', 'public', 'private'] + +/** + * Shape check for a version root from a peer (section 10): exactly its four + * members, this protocol version, metadata an object or null, a valid public + * set, and a commitment or null. Returns an error or null. + */ +export function checkVersionRoot(r: unknown): string | null { + if (!isPlainObject(r)) return 'root: not an object' + if (Object.keys(r).length !== 4 || !ROOT_MEMBERS.every((k) => Object.hasOwn(r, k))) + return 'root: unexpected fields' + if (r.underlay !== PROTOCOL_VERSION) return 'root: bad protocol version' + if (r.metadata !== null && !isPlainObject(r.metadata)) + return 'root: metadata must be an object or null' + if (r.private !== null && (typeof r.private !== 'string' || !HEX64.test(r.private))) + return 'root: bad private commitment' + const e = checkSetObject(r.public) + return e ? `public ${e}` : null +} + +/** + * Shape check for a private set object: a root names one only for a non-empty + * private set (section 10). Returns an error or null. + */ +export function checkPrivateSetObject(s: unknown): string | null { + return checkSetObject(s, true) ?? (isEmptySet(s as SetObject) ? 'set: empty' : null) +} diff --git a/src/lib/core/semver.ts b/packages/protocol/src/semver.ts similarity index 72% rename from src/lib/core/semver.ts rename to packages/protocol/src/semver.ts index de42df0..5368783 100644 --- a/src/lib/core/semver.ts +++ b/packages/protocol/src/semver.ts @@ -1,3 +1,7 @@ +/** + * Version numbers: unchanged from v1 (src/lib/core/semver.ts). Not protocol, but + * the server and the CLI must number versions the same way. + */ export interface SemverComponents { semver: string major: number @@ -17,12 +21,11 @@ export function compareSemver(a: string, b: string): number { return pa.major - pb.major || pa.minor - pb.minor || pa.patch - pb.patch } +/** Major on a schema change, minor on a record change, patch otherwise (metadata). */ export function deriveSemver( prevSemver: string | null, schemaChanged: boolean, recordsChanged: boolean, - // Accepted for caller clarity, but metadata-only and no-change commits both patch-bump. - _metadataChanged?: boolean, ): SemverComponents { if (!prevSemver) return { semver: 'v1.0.0', major: 1, minor: 0, patch: 0 } const { major, minor, patch } = parseSemver(prevSemver) @@ -31,3 +34,8 @@ export function deriveSemver( return { semver: `v${major}.${minor + 1}.0`, major, minor: minor + 1, patch: 0 } return { semver: `v${major}.${minor}.${patch + 1}`, major, minor, patch: patch + 1 } } + +export type BumpType = 'major' | 'minor' | 'patch' + +export const bumpType = (schemaChanged: boolean, recordsChanged: boolean): BumpType => + schemaChanged ? 'major' : recordsChanged ? 'minor' : 'patch' diff --git a/packages/protocol/src/sha256.ts b/packages/protocol/src/sha256.ts new file mode 100644 index 0000000..6200fa0 --- /dev/null +++ b/packages/protocol/src/sha256.ts @@ -0,0 +1,62 @@ +/** + * Synchronous SHA-256 everywhere the package runs. Node and Workers (with + * nodejs_compat) expose their native hash through `process.getBuiltinModule`, + * with no `node:` import for bundlers to trip on; browsers fall back to + * @noble/hashes, about 6× slower on large inputs (edge-redesign-build.md, + * "Package layout review"). The tree builder hashes every key and node, so it + * can't use the async WebCrypto digest. + */ +import { sha256 as nobleSha256 } from '@noble/hashes/sha2.js' + +import { utf8 } from './utf8.js' + +interface NodeHash { + update(data: string | Uint8Array): NodeHash + digest(): Uint8Array + digest(encoding: 'hex'): string +} +type GetBuiltin = (id: 'node:crypto') => { createHash(algorithm: 'sha256'): NodeHash } | undefined + +const getBuiltin = (globalThis as { process?: { getBuiltinModule?: GetBuiltin } }).process + ?.getBuiltinModule +const native = getBuiltin?.('node:crypto') + +const toBytes = (input: string | Uint8Array) => (typeof input === 'string' ? utf8(input) : input) + +/** The fallback, exported for the test that it matches the native hash. */ +export const portableSha256 = (input: string | Uint8Array) => nobleSha256(toBytes(input)) +export function portableSha256Hex(input: string | Uint8Array): string { + let out = '' + for (const b of portableSha256(input)) out += b.toString(16).padStart(2, '0') + return out +} + +/** SHA-256 of a string (as UTF-8) or bytes. */ +export function sha256(input: string | Uint8Array): Uint8Array { + return native ? native.createHash('sha256').update(input).digest() : portableSha256(input) +} + +/** Lowercase hex SHA-256 of a string (as UTF-8) or bytes. */ +export function sha256Hex(input: string | Uint8Array): string { + return native ? native.createHash('sha256').update(input).digest('hex') : portableSha256Hex(input) +} + +/** SHA-256 over bytes that arrive in pieces (a file streamed in parts). */ +export function sha256Hasher(): { update(bytes: Uint8Array): void; hex(): string } { + if (native) { + const h = native.createHash('sha256') + return { update: (b) => void h.update(b), hex: () => h.digest('hex') } + } + const h = nobleSha256.create() + return { + update: (b) => void h.update(b), + hex: () => { + let out = '' + for (const b of h.digest()) out += b.toString(16).padStart(2, '0') + return out + }, + } +} + +/** Whether hashing is native (Node, Workers) or the portable fallback (browsers). */ +export const nativeSha256 = native !== undefined diff --git a/packages/protocol/src/stores/fs.ts b/packages/protocol/src/stores/fs.ts new file mode 100644 index 0000000..5265846 --- /dev/null +++ b/packages/protocol/src/stores/fs.ts @@ -0,0 +1,356 @@ +/** + * A store in a local directory: the CLI's offline `.underlay/`, development, and + * small self-hosted Node setups. + * + * Given a public URL and a secret, it also presigns: URLs point at the app + * itself (`/_blob/`), signed with HMAC, and the Node entry serves them + * through `serveSignedBlob`. Bytes still never pass through the API routes, and + * the same client flow (presigned PUT, then GET by redirect) works as on R2. + * + * Node's modules are fetched on first use with `process.getBuiltinModule`, so + * importing the package never pulls in `node:fs` (browsers, Workers), and no + * bundler trips over a `node:` import. + */ +import type * as NodeFs from 'node:fs' +import type * as NodeFsp from 'node:fs/promises' +import type * as NodePath from 'node:path' +import type * as NodeStream from 'node:stream' + +import type { + BlobHead, + BlobObject, + ListPage, + PresignGetOptions, + Presigner, + PresigningStore, + PresignPutOptions, + PutOptions, + PutPartsOptions, + Store, +} from '../repo/types.js' + +export interface FileStoreOptions { + /** Public base URL of the app, for presigned URLs. */ + publicUrl: string + /** HMAC secret for presigned URLs. */ + secret: string +} + +interface NodeModules { + fs: typeof NodeFs + fsp: typeof NodeFsp + path: typeof NodePath + stream: typeof NodeStream +} +type GetBuiltin = (id: string) => unknown +let nodeModules: NodeModules | undefined +/** Node's modules, through `process.getBuiltinModule` so no bundler sees a `node:` import. */ +const node = async (): Promise => { + if (nodeModules) return nodeModules + const get = (globalThis as { process?: { getBuiltinModule?: GetBuiltin } }).process + ?.getBuiltinModule + if (!get) throw new Error('fileStore needs Node (process.getBuiltinModule, Node ≥ 22.3)') + return (nodeModules = { + fs: get('node:fs') as typeof NodeFs, + fsp: get('node:fs/promises') as typeof NodeFsp, + path: get('node:path') as typeof NodePath, + stream: get('node:stream') as typeof NodeStream, + }) +} + +const enc = new TextEncoder() +const hex = (b: ArrayBuffer) => + [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join('') +const unhex = (s: string) => new Uint8Array((s.match(/../g) ?? []).map((x) => parseInt(x, 16))) + +/** A store in a directory; with `presign`, one that presigns URLs served by the app. */ +export function fileStore(root: string): FileStore +export function fileStore(root: string, presign: FileStoreOptions): FileStore & PresigningStore +export function fileStore(root: string, presign?: FileStoreOptions): FileStore { + return new FileStore(root, presign) +} + +export class FileStore implements Store { + #key: Promise | undefined + readonly presigner?: Presigner + constructor( + readonly root: string, + readonly signing?: FileStoreOptions, + ) { + if (signing) { + this.presigner = { + presignGet: (key, opts) => this.#presignGet(key, opts), + presignPut: (key, opts) => this.#presignPut(key, opts), + createMultipart: (key) => this.#createMultipart(key), + presignPart: (key, id, n, exp) => this.#presignPart(key, id, n, exp), + completeMultipart: (key, id, parts) => this.#completeMultipart(key, id, parts), + abortMultipart: (key, id) => this.#abortMultipart(key, id), + } + } + } + + /** Node modules plus the absolute path of a key (which must stay under the root). */ + async #at(key: string) { + const m = await node() + const root = m.path.resolve(this.root) + const p = m.path.resolve(root, key) + if (!p.startsWith(root + m.path.sep)) throw new Error(`Bad blob key: ${key}`) + return { ...m, root, p } + } + + async get(key: string, range?: { offset: number; length?: number }): Promise { + const { fs, fsp, stream, p: path } = await this.#at(key) + const st = await fsp.stat(path).catch(() => null) + if (!st?.isFile()) return null + const start = range?.offset ?? 0 + const end = + range?.length === undefined ? st.size - 1 : Math.min(st.size - 1, start + range.length - 1) + const size = Math.max(0, end - start + 1) + const head = { + size, + etag: `"${st.mtimeMs}-${st.size}"`, + contentType: await this.#contentType(key), + } + const read = async () => { + if (size === 0) return new Uint8Array() + const fh = await fsp.open(path) + try { + const buf = new Uint8Array(size) + await fh.read(buf, 0, size, start) + return buf + } finally { + await fh.close() + } + } + return { + ...head, + get body() { + return stream.Readable.toWeb( + fs.createReadStream(path, { start, end }), + ) as ReadableStream + }, + bytes: read, + text: async () => new TextDecoder().decode(await read()), + } + } + + async #contentType(key: string): Promise { + const { fsp, p } = await this.#at(key) + return fsp.readFile(p + '.__type', 'utf8').catch(() => null) + } + + async head(key: string): Promise { + const { fsp, p } = await this.#at(key) + const st = await fsp.stat(p).catch(() => null) + if (!st?.isFile()) return null + return { + size: st.size, + etag: `"${st.mtimeMs}-${st.size}"`, + contentType: await this.#contentType(key), + } + } + + async put(key: string, body: Uint8Array | string, opts: PutOptions = {}): Promise { + const { fsp, path: P, p: path } = await this.#at(key) + if (opts.ifAbsent && (await fsp.stat(path).catch(() => null))) return + await fsp.mkdir(P.dirname(path), { recursive: true }) + // Write then rename, so a reader never sees a partial object. + const tmp = `${path}.${crypto.randomUUID()}.tmp` + await fsp.writeFile(tmp, body) + await fsp.rename(tmp, path) + if (opts.contentType) await fsp.writeFile(path + '.__type', opts.contentType) + } + + async putParts( + key: string, + parts: AsyncIterable, + opts: PutPartsOptions = {}, + ): Promise { + const { fsp, path: P, p: path } = await this.#at(key) + if (opts.ifAbsent && (await fsp.stat(path).catch(() => null))) return + await fsp.mkdir(P.dirname(path), { recursive: true }) + // Appended part by part to a temporary file, renamed only once checked. + const tmp = `${path}.${crypto.randomUUID()}.tmp` + try { + await fsp.writeFile(tmp, new Uint8Array(0)) + for await (const part of parts) await fsp.appendFile(tmp, part) + await opts.check?.() + await fsp.rename(tmp, path) + } catch (err) { + await fsp.rm(tmp, { force: true }) + throw err + } + if (opts.contentType) await fsp.writeFile(path + '.__type', opts.contentType) + } + + async delete(key: string): Promise { + const { fsp, p } = await this.#at(key) + await fsp.rm(p, { force: true }) + await fsp.rm(p + '.__type', { force: true }) + } + + async list(prefix: string, cursor?: string): Promise { + const { fsp, path: P } = await node() + const root = P.resolve(this.root) + const out: string[] = [] + const walk = async (dir: string) => { + const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []) + for (const e of entries) { + const p = P.join(dir, e.name) + if (e.isDirectory()) await walk(p) + else if (!e.name.endsWith('.__type') && !e.name.endsWith('.tmp')) { + const key = P.relative(root, p).split(P.sep).join('/') + if (key.startsWith(prefix) && (!cursor || key > cursor)) out.push(key) + } + } + } + await walk(root) + out.sort() + const page = out.slice(0, 1000) + const info = await Promise.all( + page.map(async (k) => { + const st = await fsp.stat(P.join(root, ...k.split('/'))).catch(() => null) + return { size: st?.size ?? 0, modified: st?.mtimeMs ?? 0 } + }), + ) + return page.length === 1000 + ? { keys: page, cursor: page[page.length - 1]!, info } + : { keys: page, info } + } + + #signing(): FileStoreOptions { + if (!this.signing) throw new Error('This file store was opened without presigning') + return this.signing + } + + #hmacKey(): Promise { + return (this.#key ??= crypto.subtle.importKey( + 'raw', + enc.encode(this.#signing().secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + )) + } + + async sign(method: string, key: string, exp: number, extra = ''): Promise { + const msg = enc.encode(`${method}\n${key}\n${exp}\n${extra}`) + return hex(await crypto.subtle.sign('HMAC', await this.#hmacKey(), msg)) + } + + async #signedUrl( + method: string, + key: string, + expiresIn: number, + params: Record = {}, + ) { + const exp = Math.floor(Date.now() / 1000) + expiresIn + const extra = new URLSearchParams(params).toString() + const u = new URL( + `${this.#signing().publicUrl.replace(/\/$/, '')}/_blob/${key.split('/').map(encodeURIComponent).join('/')}`, + ) + for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v) + u.searchParams.set('m', method) + u.searchParams.set('exp', String(exp)) + u.searchParams.set('sig', await this.sign(method, key, exp, extra)) + return u.toString() + } + + async #presignGet(key: string, opts: PresignGetOptions): Promise { + const params: Record = {} + if (opts.disposition) params.disposition = opts.disposition + if (opts.contentType) params.type = opts.contentType + return this.#signedUrl('GET', key, opts.expiresIn, params) + } + + async #presignPut(key: string, opts: PresignPutOptions): Promise { + return this.#signedUrl('PUT', key, opts.expiresIn) + } + + async #createMultipart(key: string): Promise { + const id = crypto.randomUUID() + const { fsp, p } = await this.#at(`_multipart/${id}`) + await fsp.mkdir(p, { recursive: true }) + await fsp.writeFile(`${p}/.key`, key) + return id + } + + async #presignPart( + key: string, + uploadId: string, + partNumber: number, + expiresIn: number, + ): Promise { + return this.#signedUrl('PUT', `_multipart/${uploadId}/${partNumber}`, expiresIn) + } + + async #completeMultipart( + key: string, + uploadId: string, + parts: { partNumber: number; etag: string }[], + ) { + const { fsp, path: P, p: path } = await this.#at(key) + const dir = (await this.#at(`_multipart/${uploadId}`)).p + await fsp.mkdir(P.dirname(path), { recursive: true }) + const tmp = `${path}.${crypto.randomUUID()}.tmp` + const fh = await fsp.open(tmp, 'w') + try { + for (const p of parts) await fh.write(await fsp.readFile(`${dir}/${p.partNumber}`)) + } finally { + await fh.close() + } + await fsp.rename(tmp, path) + await fsp.rm(dir, { recursive: true, force: true }) + } + + async copy(from: string, to: string): Promise { + const src = (await this.#at(from)).p + const { fsp, path: P, p: dest } = await this.#at(to) + await fsp.mkdir(P.dirname(dest), { recursive: true }) + const tmp = `${dest}.${crypto.randomUUID()}.tmp` + await fsp.copyFile(src, tmp) + await fsp.rename(tmp, dest) + } + + async #abortMultipart(_key: string, uploadId: string): Promise { + const { fsp, p } = await this.#at(`_multipart/${uploadId}`) + await fsp.rm(p, { recursive: true, force: true }) + } + + /** Verify a presigned `/_blob/…` request. Returns the key, or null if the signature is bad or expired. */ + async verify(method: string, url: URL): Promise { + const key = decodeURIComponent(url.pathname.replace(/^\/_blob\//, '')) + const exp = Number(url.searchParams.get('exp')) + const sig = url.searchParams.get('sig') ?? '' + if (url.searchParams.get('m') !== method || !Number.isFinite(exp) || exp < Date.now() / 1000) + return null + const params: Record = {} + for (const p of ['disposition', 'type']) { + const v = url.searchParams.get(p) + if (v !== null) params[p] = v + } + const msg = enc.encode(`${method}\n${key}\n${exp}\n${new URLSearchParams(params).toString()}`) + // Constant-time comparison is subtle.verify's job. + const ok = await crypto.subtle.verify('HMAC', await this.#hmacKey(), unhex(sig), msg) + return ok ? key : null + } +} + +/** Serve a presigned `/_blob/…` GET or PUT for a presigning file store (Node entry only). */ +export async function serveSignedBlob(store: FileStore, req: Request): Promise { + const url = new URL(req.url) + const key = await store.verify(req.method, url) + if (!key) return new Response('Forbidden', { status: 403 }) + if (req.method === 'PUT') { + await store.put(key, new Uint8Array(await req.arrayBuffer())) + return new Response(null, { status: 200, headers: { etag: `"${key.split('/').pop()}"` } }) + } + const obj = await store.get(key) + if (!obj) return new Response('Not found', { status: 404 }) + const headers: Record = { 'content-length': String(obj.size) } + const type = url.searchParams.get('type') ?? obj.contentType + if (type) headers['content-type'] = type + const disposition = url.searchParams.get('disposition') + if (disposition) headers['content-disposition'] = disposition + return new Response(obj.body, { headers }) +} diff --git a/packages/protocol/src/stores/memory.ts b/packages/protocol/src/stores/memory.ts new file mode 100644 index 0000000..c9a9957 --- /dev/null +++ b/packages/protocol/src/stores/memory.ts @@ -0,0 +1,132 @@ +import { + type BlobHead, + type BlobObject, + joinParts, + type ListPage, + type Presigner, + type PutOptions, + type PutPartsOptions, + type Store, +} from '../repo/types.js' + +const enc = new TextEncoder() + +export function blobObject(bytes: Uint8Array, head: Omit): BlobObject { + return { + ...head, + size: bytes.byteLength, + body: new Blob([bytes as Uint8Array]).stream(), + bytes: async () => bytes, + text: async () => new TextDecoder().decode(bytes), + } +} + +/** An in-memory store, for tests and scratch work. Presigned URLs are `memory://` placeholders. */ +export function memoryStore(): MemoryStore { + return new MemoryStore() +} + +export class MemoryStore implements Store { + readonly objects = new Map< + string, + { bytes: Uint8Array; contentType: string | null; cacheControl?: string; modified?: number } + >() + readonly multipart = new Map>() + puts = 0 + gets = 0 + + async get(key: string, range?: { offset: number; length?: number }) { + this.gets++ + const o = this.objects.get(key) + if (!o) return null + const bytes = range + ? o.bytes.subarray( + range.offset, + range.length === undefined ? undefined : range.offset + range.length, + ) + : o.bytes + return blobObject(bytes, { etag: `"${key}"`, contentType: o.contentType }) + } + + async head(key: string) { + const o = this.objects.get(key) + return o ? { size: o.bytes.byteLength, etag: `"${key}"`, contentType: o.contentType } : null + } + + async put(key: string, body: Uint8Array | string, opts: PutOptions = {}) { + if (opts.ifAbsent && this.objects.has(key)) return + this.puts++ + this.objects.set(key, { + bytes: typeof body === 'string' ? enc.encode(body) : body.slice(), + contentType: opts.contentType ?? null, + ...(opts.cacheControl ? { cacheControl: opts.cacheControl } : {}), + modified: Date.now(), + }) + } + + async putParts(key: string, parts: AsyncIterable, opts: PutPartsOptions = {}) { + const bytes = await joinParts(parts) + await opts.check?.() + const { check: _, ...put } = opts + await this.put(key, bytes, put) + } + + async delete(key: string) { + this.objects.delete(key) + } + + async list(prefix: string, cursor?: string): Promise { + const keys = [...this.objects.keys()] + .filter((k) => k.startsWith(prefix) && (!cursor || k > cursor)) + .sort() + const page = keys.slice(0, 1000) + const info = page.map((k) => { + const o = this.objects.get(k)! + // Objects set directly (tests) have no write time: they count as old. + return { size: o.bytes.byteLength, modified: o.modified ?? 0 } + }) + return page.length === 1000 + ? { keys: page, cursor: page[page.length - 1]!, info } + : { keys: page, info } + } + + readonly presigner: Presigner = { + presignGet: async (key) => `memory://get/${key}`, + presignPut: async (key) => `memory://put/${key}`, + createMultipart: async (key) => { + const id = crypto.randomUUID() + this.multipart.set(`${key}#${id}`, new Map()) + return id + }, + presignPart: async (key, uploadId, partNumber) => + `memory://part/${key}?uploadId=${uploadId}&partNumber=${partNumber}`, + completeMultipart: async (key, uploadId, parts) => { + const stored = this.multipart.get(`${key}#${uploadId}`) + if (!stored) throw new Error('No such upload') + const chunks = parts.map((p) => stored.get(p.partNumber)!) + const out = new Uint8Array(chunks.reduce((n, c) => n + c.byteLength, 0)) + let off = 0 + for (const c of chunks) { + out.set(c, off) + off += c.byteLength + } + this.objects.set(key, { bytes: out, contentType: null }) + this.multipart.delete(`${key}#${uploadId}`) + }, + abortMultipart: async (key, uploadId) => { + this.multipart.delete(`${key}#${uploadId}`) + }, + } + + /** Test helper standing in for a client PUT to a presigned part URL. */ + uploadPart(key: string, uploadId: string, partNumber: number, bytes: Uint8Array): string { + this.multipart.get(`${key}#${uploadId}`)!.set(partNumber, bytes) + return `"part-${partNumber}"` + } + + async copy(from: string, to: string) { + const o = this.objects.get(from) + if (!o) throw new Error(`No such key ${from}`) + this.objects.set(to, { bytes: o.bytes.slice(), contentType: o.contentType }) + } +} diff --git a/packages/protocol/src/stores/r2.ts b/packages/protocol/src/stores/r2.ts new file mode 100644 index 0000000..0a6fd41 --- /dev/null +++ b/packages/protocol/src/stores/r2.ts @@ -0,0 +1,160 @@ +/** + * A store over a Workers R2 binding: the platform primary on Workers, and + * Miniflare in tests. The binding can't presign or copy, so the platform keeps + * an `s3Store` on the same bucket for presigned file URLs, and `copyObject` + * reads and writes. + * + * The binding's types are declared structurally here, so the package needs no + * Workers type definitions. + */ +import { + type BlobHead, + type BlobObject, + joinParts, + type ListPage, + type PutOptions, + type PutPartsOptions, + type Store, + writeInParts, +} from '../repo/types.js' + +interface R2ObjectLike { + size: number + httpEtag: string + httpMetadata?: { contentType?: string } +} + +interface R2ObjectBodyLike extends R2ObjectLike { + body: ReadableStream + arrayBuffer(): Promise + text(): Promise +} + +/** The parts of an R2 bucket binding this store uses. */ +export interface R2BucketLike { + get( + key: string, + options?: { range?: { offset: number; length?: number } }, + ): Promise + head(key: string): Promise + put( + key: string, + value: Uint8Array | string, + options?: { + httpMetadata?: { contentType?: string; cacheControl?: string } + onlyIf?: Headers + }, + ): Promise + delete(key: string): Promise + /** Multipart uploads (Workers bindings have them; a stand-in may not). */ + createMultipartUpload?( + key: string, + options?: { httpMetadata?: { contentType?: string } }, + ): Promise<{ + uploadPart(partNumber: number, value: Uint8Array): Promise<{ partNumber: number; etag: string }> + complete(parts: { partNumber: number; etag: string }[]): Promise + abort(): Promise + }> + list(options: { prefix?: string; cursor?: string; limit?: number }): Promise<{ + objects: { key: string; size?: number; uploaded?: Date }[] + truncated: boolean + cursor?: string + }> +} + +/** A store over an R2 binding. */ +export function r2Store(bucket: R2BucketLike): Store { + return new R2Store(bucket) +} + +const headOf = (o: R2ObjectLike): BlobHead => ({ + size: o.size, + etag: o.httpEtag, + contentType: o.httpMetadata?.contentType ?? null, +}) + +class R2Store implements Store { + constructor(readonly bucket: R2BucketLike) {} + + async get(key: string, range?: { offset: number; length?: number }): Promise { + if (range) { + const obj = await this.bucket.get(key, { range }) + if (!obj) return null + // `size` is the whole object's; the contract wants the range's. + const rest = Math.max(0, obj.size - range.offset) + return this.#object(obj, Math.min(range.length ?? rest, rest)) + } + const obj = await this.bucket.get(key) + return obj ? this.#object(obj, obj.size) : null + } + + #object(obj: R2ObjectBodyLike, size: number): BlobObject { + return { + ...headOf(obj), + size, + get body() { + return obj.body + }, + bytes: async () => new Uint8Array(await obj.arrayBuffer()), + text: () => obj.text(), + } + } + + async head(key: string): Promise { + const o = await this.bucket.head(key) + return o ? headOf(o) : null + } + + async put(key: string, body: Uint8Array | string, opts: PutOptions = {}): Promise { + // A failed `If-None-Match: *` precondition returns null: the key exists, which + // for immutable keys is success. + const httpMetadata = { + ...(opts.contentType ? { contentType: opts.contentType } : {}), + ...(opts.cacheControl ? { cacheControl: opts.cacheControl } : {}), + } + await this.bucket.put(key, body, { + ...(Object.keys(httpMetadata).length ? { httpMetadata } : {}), + ...(opts.ifAbsent ? { onlyIf: new Headers({ 'if-none-match': '*' }) } : {}), + }) + } + + async putParts( + key: string, + parts: AsyncIterable, + opts: PutPartsOptions = {}, + ): Promise { + const { check, ...put } = opts + const multipart = this.bucket.createMultipartUpload?.bind(this.bucket) + if (!multipart) { + const bytes = await joinParts(parts) + await check?.() + return this.put(key, bytes, put) + } + if (opts.ifAbsent && (await this.bucket.head(key))) return + await writeInParts(parts, check, { + single: (bytes) => this.put(key, bytes, put), + begin: async () => { + const upload = await multipart( + key, + opts.contentType ? { httpMetadata: { contentType: opts.contentType } } : {}, + ) + return { + part: (n, bytes) => upload.uploadPart(n, bytes), + complete: async (done) => void (await upload.complete(done)), + abort: () => upload.abort(), + } + }, + }) + } + + async delete(key: string): Promise { + await this.bucket.delete(key) + } + + async list(prefix: string, cursor?: string): Promise { + const r = await this.bucket.list({ prefix, limit: 1000, ...(cursor ? { cursor } : {}) }) + const keys = r.objects.map((o) => o.key) + const info = r.objects.map((o) => ({ size: o.size ?? 0, modified: o.uploaded?.getTime() ?? 0 })) + return r.truncated && r.cursor ? { keys, cursor: r.cursor, info } : { keys, info } + } +} diff --git a/packages/protocol/src/stores/s3.ts b/packages/protocol/src/stores/s3.ts new file mode 100644 index 0000000..99cd367 --- /dev/null +++ b/packages/protocol/src/stores/s3.ts @@ -0,0 +1,434 @@ +/** + * A store over the S3 API, signed with aws4fetch. Works against R2, S3 and + * MinIO, from Workers and Node alike, and can presign (which the R2 binding + * can't). Path-style URLs: `${endpoint}/${bucket}/${key}`. + */ +import { AwsClient } from 'aws4fetch' + +import type { + BlobHead, + BlobObject, + ListPage, + PresignGetOptions, + Presigner, + PresignPutOptions, + PutOptions, + PutPartsOptions, + Store, +} from '../repo/types.js' +import { writeInParts } from '../repo/types.js' + +export interface S3Config { + endpoint: string + bucket: string + accessKeyId: string + secretAccessKey: string + /** "auto" for R2. */ + region?: string + /** + * fetch for the signed requests. The platform passes a guarded fetch for + * customer endpoints (user-supplied URLs); the default is the global fetch, + * with aws4fetch's retries. + */ + fetch?: (req: Request) => Promise +} + +export class S3Error extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message) + this.name = 'S3Error' + } +} + +/** Keys are path segments; encode each one but keep the slashes. */ +const encodeKey = (key: string) => key.split('/').map(encodeURIComponent).join('/') + +const xmlValues = (xml: string, tag: string) => + [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)`, 'g'))].map((m) => + m[1]! + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"), + ) + +const escapeXml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + +/** A store over the S3 API (R2, S3, MinIO); presigns. */ +export function s3Store(cfg: S3Config): S3Store { + return new S3Store(cfg) +} + +/** An enabled bucket lifecycle rule, as far as it touches stored objects. */ +export interface LifecycleRule { + id: string + /** The key prefix it applies to ('' for the whole bucket). */ + prefix: string + /** It deletes current objects (Expiration). */ + expires: boolean + /** It moves objects to another storage class (Transition), where reads may fail. */ + transitions: boolean +} + +const tag = (xml: string, name: string) => + new RegExp(`<${name}>([\\s\\S]*?)`).exec(xml)?.[1] ?? null + +export const s3CopyLimits = { + /** CopyObject's limit; larger objects are copied in parts. */ + copyObjectMax: 5 * 1024 ** 3, + /** Part size for multipart copies: 512 MiB, so 10,000 parts cover 5 TiB. */ + partBytes: 512 * 1024 ** 2, +} + +export class S3Store implements Store { + readonly #aws: AwsClient + readonly #base: string + readonly #custom: S3Config['fetch'] + + constructor(cfg: S3Config) { + this.#custom = cfg.fetch + this.#aws = new AwsClient({ + accessKeyId: cfg.accessKeyId, + secretAccessKey: cfg.secretAccessKey, + service: 's3', + region: cfg.region ?? 'auto', + retries: 3, + }) + this.#base = `${cfg.endpoint.replace(/\/$/, '')}/${cfg.bucket}` + } + + async #fetch(url: string, init: RequestInit = {}): Promise { + const custom = this.#custom + if (!custom) return this.#aws.fetch(url, init) + // Signed once, sent through the given fetch, retried on network errors and 5xx. + const req = await this.#aws.sign(url, init) + for (let attempt = 0; ; attempt++) { + try { + const res = await custom(req.clone()) + if (res.status < 500 || attempt >= 2) return res + await res.body?.cancel() + } catch (err) { + if (attempt >= 2) throw err + } + await new Promise((r) => setTimeout(r, 100 * 2 ** attempt)) + } + } + + readonly presigner: Presigner = { + presignGet: (key, opts) => this.#presignGet(key, opts), + presignPut: (key, opts) => this.#presignPut(key, opts), + createMultipart: (key, type) => this.#createMultipart(key, type), + presignPart: (key, id, n, exp) => this.#presignPart(key, id, n, exp), + completeMultipart: (key, id, parts) => this.#completeMultipart(key, id, parts), + abortMultipart: (key, id) => this.#abortMultipart(key, id), + } + + #url(key: string, query?: Record) { + const u = new URL(`${this.#base}/${encodeKey(key)}`) + for (const [k, v] of Object.entries(query ?? {})) u.searchParams.set(k, v) + return u.toString() + } + + async #fail(res: Response, what: string): Promise { + const body = await res.text().catch(() => '') + throw new S3Error( + res.status, + `S3 ${what}: ${res.status} ${xmlValues(body, 'Code')[0] ?? body.slice(0, 200)}`, + ) + } + + #head(res: Response): BlobHead { + return { + size: Number(res.headers.get('content-length') ?? 0), + etag: res.headers.get('etag') ?? '', + contentType: res.headers.get('content-type'), + } + } + + async get(key: string, range?: { offset: number; length?: number }): Promise { + const headers: Record = {} + if (range) { + headers.range = + range.length === undefined + ? `bytes=${range.offset}-` + : `bytes=${range.offset}-${range.offset + range.length - 1}` + } + const res = await this.#fetch(this.#url(key), { headers }) + if (res.status === 404) { + await res.body?.cancel() + return null + } + if (!res.ok) return this.#fail(res, `GET ${key}`) + const head = this.#head(res) + let used = false + const take = () => { + if (used) throw new Error('Blob body already consumed') + used = true + return res + } + return { + ...head, + get body() { + return take().body! + }, + bytes: async () => new Uint8Array(await take().arrayBuffer()), + text: async () => take().text(), + } + } + + async head(key: string): Promise { + const res = await this.#fetch(this.#url(key), { method: 'HEAD' }) + if (res.status === 404) return null + if (!res.ok) return this.#fail(res, `HEAD ${key}`) + return this.#head(res) + } + + async put(key: string, body: Uint8Array | string, opts: PutOptions = {}): Promise { + const headers: Record = {} + if (opts.contentType) headers['content-type'] = opts.contentType + if (opts.cacheControl) headers['cache-control'] = opts.cacheControl + if (opts.ifAbsent) headers['if-none-match'] = '*' + const res = await this.#fetch(this.#url(key), { + method: 'PUT', + headers, + body: body as BodyInit, + }) + // 412: the key exists and ifAbsent was asked for. Keys are immutable, so that's success. + if (res.status === 412 && opts.ifAbsent) { + await res.body?.cancel() + return + } + if (!res.ok) return this.#fail(res, `PUT ${key}`) + await res.body?.cancel() + } + + async delete(key: string): Promise { + const res = await this.#fetch(this.#url(key), { method: 'DELETE' }) + if (!res.ok && res.status !== 404) return this.#fail(res, `DELETE ${key}`) + await res.body?.cancel() + } + + async list(prefix: string, cursor?: string): Promise { + const u = new URL(this.#base) + u.searchParams.set('list-type', '2') + u.searchParams.set('prefix', prefix) + if (cursor) u.searchParams.set('continuation-token', cursor) + const res = await this.#fetch(u.toString()) + if (!res.ok) return this.#fail(res, `LIST ${prefix}`) + const xml = await res.text() + const next = xmlValues(xml, 'NextContinuationToken')[0] + const keys: string[] = [] + const info: { size: number; modified: number }[] = [] + // Each block raw (entities are decoded once, per value). + for (const [, c] of xml.matchAll(/([\s\S]*?)<\/Contents>/g)) { + keys.push(xmlValues(c!, 'Key')[0]!) + const modified = Date.parse(xmlValues(c!, 'LastModified')[0] ?? '') + info.push({ + size: Number(xmlValues(c!, 'Size')[0] ?? 0), + modified: Number.isNaN(modified) ? 0 : modified, + }) + } + return next ? { keys, cursor: next, info } : { keys, info } + } + + async #presign(url: string, method: string, expiresIn: number, headers?: Record) { + const u = new URL(url) + u.searchParams.set('X-Amz-Expires', String(expiresIn)) + const signed = await this.#aws.sign(u.toString(), { + method, + ...(headers ? { headers } : {}), + aws: { signQuery: true }, + }) + return signed.url + } + + #presignGet(key: string, opts: PresignGetOptions): Promise { + const q: Record = {} + if (opts.disposition) q['response-content-disposition'] = opts.disposition + if (opts.contentType) q['response-content-type'] = opts.contentType + return this.#presign(this.#url(key, q), 'GET', opts.expiresIn) + } + + /** A multipart upload, each part a signed UploadPart: no presigned URLs, no whole object held. */ + async putParts( + key: string, + parts: AsyncIterable, + opts: PutPartsOptions = {}, + ): Promise { + const { check, ...put } = opts + if (opts.ifAbsent && (await this.head(key))) return + await writeInParts(parts, check, { + single: (bytes) => this.put(key, bytes, put), + begin: async () => { + const id = await this.#createMultipart(key, opts.contentType) + return { + part: async (partNumber, bytes) => { + const res = await this.#fetch( + this.#url(key, { partNumber: String(partNumber), uploadId: id }), + { method: 'PUT', body: bytes as BodyInit }, + ) + if (!res.ok) return this.#fail(res, `UploadPart ${partNumber} of ${key}`) + await res.body?.cancel() + return { partNumber, etag: res.headers.get('etag') ?? '' } + }, + complete: (done) => this.#completeMultipart(key, id, done), + abort: () => this.#abortMultipart(key, id), + } + }, + }) + } + + /** + * The bucket's enabled lifecycle rules: 'none' when it has none, 'unreadable' + * when these credentials may not read them (GetBucketLifecycleConfiguration). + */ + async lifecycleRules(): Promise { + const res = await this.#fetch(this.#url('', { lifecycle: '' })) + const text = await res.text() + if (res.status === 404 || text.includes('NoSuchLifecycleConfiguration')) return 'none' + if (res.status === 403 || res.status === 401 || res.status === 501) return 'unreadable' + if (!res.ok) + throw new S3Error(res.status, `GetBucketLifecycleConfiguration: ${text.slice(0, 200)}`) + const rules: LifecycleRule[] = [] + for (const m of text.matchAll(/([\s\S]*?)<\/Rule>/g)) { + const r = m[1]! + if (tag(r, 'Status') !== 'Enabled') continue + const filter = tag(r, 'Filter') + // A rule limited to tagged objects never touches untagged ones (Underlay tags none). + if (filter !== null && //.test(filter)) continue + const prefix = (filter !== null ? tag(filter, 'Prefix') : tag(r, 'Prefix')) ?? '' + rules.push({ + id: tag(r, 'ID') ?? '', + prefix, + expires: tag(r, 'Expiration') !== null, + transitions: //.test(r), + }) + } + return rules.length ? rules : 'none' + } + + #presignPut(key: string, opts: PresignPutOptions): Promise { + return this.#presign( + this.#url(key), + 'PUT', + opts.expiresIn, + opts.contentType ? { 'content-type': opts.contentType } : undefined, + ) + } + + async #createMultipart(key: string, contentType?: string): Promise { + const res = await this.#fetch(`${this.#url(key)}?uploads`, { + method: 'POST', + headers: contentType ? { 'content-type': contentType } : {}, + }) + if (!res.ok) return this.#fail(res, `CreateMultipartUpload ${key}`) + const id = xmlValues(await res.text(), 'UploadId')[0] + if (!id) throw new S3Error(500, 'CreateMultipartUpload: no UploadId') + return id + } + + #presignPart( + key: string, + uploadId: string, + partNumber: number, + expiresIn: number, + ): Promise { + return this.#presign( + this.#url(key, { partNumber: String(partNumber), uploadId }), + 'PUT', + expiresIn, + ) + } + + async #completeMultipart( + key: string, + uploadId: string, + parts: { partNumber: number; etag: string }[], + ): Promise { + const xml = + '' + + parts + .map( + (p) => + `${p.partNumber}${escapeXml(p.etag)}`, + ) + .join('') + + '' + const res = await this.#fetch(this.#url(key, { uploadId }), { method: 'POST', body: xml }) + const text = await res.text() + // S3 can answer 200 with an body for a failed completion. + if (!res.ok || text.includes('')) { + throw new S3Error( + res.ok ? 500 : res.status, + `CompleteMultipartUpload ${key}: ${text.slice(0, 200)}`, + ) + } + } + + /** + * CopyObject, or for an object over 5 GiB (CopyObject's limit; pass its size) + * a multipart copy whose parts are UploadPartCopy ranges of the source. + */ + async copy(from: string, to: string, size?: number): Promise { + const source = `/${this.#base.split('/').pop()}/${encodeKey(from)}` + if (size !== undefined && size > s3CopyLimits.copyObjectMax) + return this.#copyInParts(source, to, size) + const res = await this.#fetch(this.#url(to), { + method: 'PUT', + headers: { 'x-amz-copy-source': source }, + }) + const text = await res.text() + // Like CompleteMultipartUpload, CopyObject can answer 200 with an body. + if (!res.ok || text.includes('')) { + throw new S3Error( + res.ok ? 500 : res.status, + `CopyObject ${from} → ${to}: ${text.slice(0, 200)}`, + ) + } + } + + async #copyInParts(source: string, to: string, size: number): Promise { + const id = await this.#createMultipart(to) + try { + const parts: { partNumber: number; etag: string }[] = [] + for (let start = 0; start < size; start += s3CopyLimits.partBytes) { + const partNumber = parts.length + 1 + const end = Math.min(size, start + s3CopyLimits.partBytes) - 1 + const res = await this.#fetch( + this.#url(to, { partNumber: String(partNumber), uploadId: id }), + { + method: 'PUT', + headers: { + 'x-amz-copy-source': source, + 'x-amz-copy-source-range': `bytes=${start}-${end}`, + }, + }, + ) + const text = await res.text() + const etag = /(.*?)<\/ETag>/.exec(text)?.[1] + if (!res.ok || text.includes('') || !etag) { + throw new S3Error( + res.ok ? 500 : res.status, + `UploadPartCopy ${to}: ${text.slice(0, 200)}`, + ) + } + parts.push({ partNumber, etag: etag.replace(/"/g, '"') }) + } + await this.#completeMultipart(to, id, parts) + } catch (err) { + await this.#abortMultipart(to, id).catch(() => {}) + throw err + } + } + + async #abortMultipart(key: string, uploadId: string): Promise { + const res = await this.#fetch(this.#url(key, { uploadId }), { method: 'DELETE' }) + if (!res.ok && res.status !== 404) return this.#fail(res, `AbortMultipartUpload ${key}`) + await res.body?.cancel() + } +} diff --git a/packages/protocol/src/tree/assemble.ts b/packages/protocol/src/tree/assemble.ts new file mode 100644 index 0000000..04981c8 --- /dev/null +++ b/packages/protocol/src/tree/assemble.ts @@ -0,0 +1,144 @@ +/** + * Assembly: the second half of a parallel commit. + * + * Commit units (`mergeTree` in range mode) each output the new leaves of one key + * range. `assembleTree` walks the base tree like `mergeTree` and, where the + * serial merge would rewrite leaves, inserts the units' leaves with `addNode` + * instead. That is valid because a segment starts and ends at natural leaf + * boundaries, so the builder's leaf level is empty at both ends. Base subtrees + * between segments are reused whole, as in `mergeTree`; the result is the tree + * the serial merge would have built, byte for byte. + * + * What makes a list of segments valid (the planner keeps these): + * - segments are sorted and disjoint, and every change lies in one; + * - each non-null `after`/`through` is a natural leaf boundary present in the + * new entry set; + * - an `after` that isn't a base key is the `through` of the previous segment; + * - a gap between segments holds no changes. + * + * So the only base leaves that straddle a segment edge are ones holding an + * unchanged `after` (whose head is copied before the segment) or a `through` + * that was inserted (whose tail is re-chunked after it). Assembly reads at most + * a leaf or two per segment, plus the interior path down to them. + */ +import { compareUtf8 } from '../utf8.js' +import { type BuilderOptions, TreeBuilder, type TreeSink } from './builder.js' +import { emptyStats, type KeyRange, type MergeResult } from './merge.js' +import type { NodeDesc } from './node.js' +import { type NodeSource, resolveRoot, rootDesc } from './source.js' + +/** + * One unit's output: its range and the leaves it produced, in key order. The + * leaves can be a loader, called once when assembly reaches the segment, so a + * large commit never holds every unit's leaves at once. + */ +export interface Segment extends KeyRange { + leaves: readonly NodeDesc[] | (() => Promise) +} + +/** Build the new tree from the base and the units' segments. */ +export async function assembleTree( + source: NodeSource, + sink: TreeSink, + base: string | null, + segments: readonly Segment[], + opts: Omit, 'leafOutput'> = {}, +): Promise { + const spec = source.spec + const stats = emptyStats() + for (let i = 1; i < segments.length; i++) { + const prev = segments[i - 1]!.through + const after = segments[i]!.after + if (prev === null || after === null || compareUtf8(prev, after) > 0) { + throw new Error('assembleTree: segments must be sorted and disjoint') + } + } + if (segments.length === 0) + return { root: base === null ? null : await rootDesc(source, base), stats } + + const builder = new TreeBuilder(spec, sink, opts) + let head = 0 + // Everything up to `covered` is in the output; `all` once a segment ran to the end. + let covered: string | null = null + let all = false + const isCovered = (key: string) => all || (covered !== null && compareUtf8(key, covered) <= 0) + + const emit = async (seg: Segment) => { + const leaves = typeof seg.leaves === 'function' ? await seg.leaves() : seg.leaves + for (const leaf of leaves) builder.addNode(leaf) + if (seg.through === null) all = true + else covered = seg.through + head++ + } + /** Does the head segment start before `lastKey`, i.e. touch a node ending there? */ + const overlaps = (lastKey: string) => { + const seg = segments[head] + return seg !== undefined && (seg.after === null || compareUtf8(lastKey, seg.after) > 0) + } + + const visit = async (desc: NodeDesc, rightEdge: boolean, low: string | null): Promise => { + if (isCovered(desc.lastKey)) return + // Reusable when no segment touches it, no part of it is already covered (a + // base leaf can straddle an inserted `through`), the builder sits on a + // boundary at its level, and it isn't a right-edge node that segments will + // be appended after. + const whole = covered === null || (low !== null && compareUtf8(low, covered) >= 0) + if ( + !overlaps(desc.lastKey) && + whole && + builder.emptyThrough(desc.level) && + !(rightEdge && head < segments.length) + ) { + stats.reusedNodes++ + builder.addNode(desc) + return + } + stats.readNodes++ + if (desc.level === 0) { + const entries = await source.leafEntries(desc.hash) + let i = 0 + while (overlaps(desc.lastKey)) { + const seg = segments[head]! + // Under the rules a segment never starts inside a base leaf with + // uncovered entries before it: an unchanged `after` ends its base leaf, + // and an inserted one was covered by the previous segment. + if (seg.after !== null) { + for (; i < entries.length && compareUtf8(spec.key(entries[i]!), seg.after) <= 0; i++) { + if (!isCovered(spec.key(entries[i]!))) { + throw new Error( + `assembleTree: segment after ${JSON.stringify(seg.after)} starts inside a base leaf`, + ) + } + } + } + await emit(seg) + if (all) return + } + // The tail of a base leaf straddling an inserted `through`. + for (const e of entries) if (!isCovered(spec.key(e))) builder.addEntry(e) + await sink.drain?.() + return + } + const node = await source.node(desc.hash) + if (node.kind !== 'node' || node.level !== desc.level) { + throw new Error(`assembleTree: node ${desc.hash} is not at level ${desc.level}`) + } + const last = node.children.length - 1 + for (let i = 0; i <= last && !all; i++) { + await visit( + node.children[i]!, + rightEdge && i === last, + i === 0 ? low : node.children[i - 1]!.lastKey, + ) + } + } + + if (base !== null) { + stats.readNodes++ + await visit(await rootDesc(source, base), true, null) + } + while (head < segments.length) await emit(segments[head]!) + + const { root, unresolved } = builder.finish() + return { root: root && unresolved ? await resolveRoot(source, root) : root, stats } +} diff --git a/packages/protocol/src/tree/builder.ts b/packages/protocol/src/tree/builder.ts new file mode 100644 index 0000000..360362e --- /dev/null +++ b/packages/protocol/src/tree/builder.ts @@ -0,0 +1,261 @@ +/** + * Streaming tree builder. + * + * Feed entries in strictly increasing key order (UTF-8 byte order) with + * `addEntry`, or whole existing nodes with `addNode`, then call `finish` for the + * root. Each level has its own chunker; when a node ends, the builder hands it to + * the sink and pushes its descriptor into the level above. Memory is one pending + * node per level. + * + * `addNode` is what makes incremental commits O(changes): a node from the base + * tree can be reused unchanged when every level at or below it is empty, i.e. + * the output stream is sitting exactly on a boundary. The node's own end was a + * boundary in the base tree (or the end of the tree), and boundaries depend only + * on keys and the position since the previous boundary, so it ends in the same + * place here. Its descriptor goes into the level above as if it had just been + * built. + * + * The root is the only node of the first level that has exactly one node. A + * level above it may have started a node with that one child (when its last key + * also satisfies the higher boundary); that node is written but unreachable, + * which is harmless and rare. + */ +import { compareUtf8 } from '../utf8.js' +import { boundaryBytes, type Chunking, protocolChunking } from './chunking.js' +import { encodeInterior, encodeLeaf, hashNode, type NodeDesc, type TreeSpec } from './node.js' + +export interface TreeSink { + /** + * A finished leaf. `entries` are all of the leaf's entries; the first `spilled` + * of them were already passed to `spill` (and may have dropped their payloads). + */ + leaf(desc: NodeDesc, json: string, entries: readonly E[], spilled: number): void + interior(desc: NodeDesc, json: string, children: readonly NodeDesc[]): void + /** + * Called when the pending leaf's payload passes `spillBytes`, with the entries + * added since the last spill, so a sink can write record bodies in bounded parts + * instead of holding a whole leaf's bodies in memory. + */ + spill?(entries: readonly E[]): void + /** + * Backpressure for sinks that write asynchronously: async callers (mergeTree) + * await it between leaves so pending writes stay bounded. + */ + drain?(): Promise +} + +export interface BuilderOptions { + chunking?: Chunking + /** Payload size of an entry (e.g. record body length). Needed for spilling. */ + payloadBytes?: (e: E) => number + /** Drop an entry's payload after it was spilled. */ + dropPayload?: (e: E) => void + /** Spill the pending leaf's payload when it reaches this many bytes. */ + spillBytes?: number + /** + * Leaves-only mode, for commit units: every finished or reused leaf goes here + * instead of into level 1. No interior node is built, `addNode` takes leaves + * only, and `finish` returns a null root. + */ + leafOutput?: (desc: NodeDesc) => void +} + +export class TreeBuilder { + readonly #spec: TreeSpec + readonly #sink: TreeSink + readonly #chunking: Chunking + readonly #opts: BuilderOptions + + // Level 0: pending leaf entries. + #leaf: E[] = [] + #leafBytes = 0 + #spilled = 0 + #payload = 0 + // Level h ≥ 1: pending children (level h−1 descriptors) of the level-h node. + readonly #pending: NodeDesc[][] = [] + // Nodes completed (built or reused) at each level, and the most recent one. + readonly #nodes: number[] = [] + readonly #last: NodeDesc[] = [] + // The last interior node built at each level, with its children (for finish). + readonly #lastBuilt: ({ hash: string; children: NodeDesc[] } | undefined)[] = [] + #lastKey: string | null = null + #finished = false + + constructor(spec: TreeSpec, sink: TreeSink, opts: BuilderOptions = {}) { + this.#spec = spec + this.#sink = sink + this.#chunking = opts.chunking ?? protocolChunking + this.#opts = opts + } + + /** True when no level at or below `level` holds a partial node. */ + emptyThrough(level: number): boolean { + if (this.#leaf.length > 0) return false + for (let h = 1; h <= level; h++) { + if ((this.#pending[h]?.length ?? 0) > 0) return false + } + return true + } + + get lastKey(): string | null { + return this.#lastKey + } + + addEntry(e: E): void { + const key = this.#spec.key(e) + this.#advance(key) + this.#leaf.push(e) + this.#leafBytes += this.#spec.bytes(e) + const { payloadBytes, spillBytes } = this.#opts + if (payloadBytes && spillBytes !== undefined) { + this.#payload += payloadBytes(e) + if (this.#payload >= spillBytes) this.#spill() + } + if (this.#chunking.ends(0, boundaryBytes(key), this.#leaf.length)) this.#emitLeaf() + } + + /** Reuse an existing node. Requires `emptyThrough(desc.level)`. */ + addNode(desc: NodeDesc): void { + if (this.#opts.leafOutput && desc.level > 0) { + throw new Error('TreeBuilder.addNode: only leaves can be added in leaves-only mode') + } + if (!this.emptyThrough(desc.level)) { + throw new Error('TreeBuilder.addNode: a lower level holds a partial node') + } + this.#advance(desc.lastKey) + this.#push(desc) + } + + /** + * Finish every level and return the root, or null for an empty tree. + * + * The root is the node of the lowest level that has exactly one node. Flush + * levels upward until the highest level holds a single node, then walk down + * while that node has a single child. A node reused through `addNode` has + * children the builder never saw; if the walk reaches one, `unresolved` is set + * and the caller finishes the walk by loading nodes (`resolveRoot`). + */ + finish(): { root: NodeDesc | null; unresolved: boolean } { + if (this.#finished) throw new Error('TreeBuilder.finish called twice') + this.#finished = true + if (this.#leaf.length > 0) this.#emitLeaf() + // No levels above 0 exist, so the flush loop below would never end. + if (this.#opts.leafOutput) return { root: null, unresolved: false } + if (this.#nodes.length === 0) return { root: null, unresolved: false } + for (let h = 0; ; h++) { + const top = this.#nodes.length - 1 + if (h === top && this.#nodes[h] === 1) break + if ((this.#pending[h + 1]?.length ?? 0) > 0) this.#emitInterior(h + 1) + } + let root = this.#last[this.#nodes.length - 1]! + while (root.level > 0) { + // The chain down from the top is always the last node of each level. + const built = this.#lastBuilt[root.level] + const kids = built?.hash === root.hash ? built.children : undefined + if (!kids) return { root, unresolved: true } + if (kids.length !== 1) break + root = kids[0]! + } + return { root, unresolved: false } + } + + #advance(key: string): void { + if (this.#finished) throw new Error('TreeBuilder: add after finish') + if (this.#lastKey !== null && compareUtf8(this.#lastKey, key) >= 0) { + throw new Error( + `TreeBuilder: keys out of order (${JSON.stringify(key)} after ${JSON.stringify(this.#lastKey)})`, + ) + } + this.#lastKey = key + } + + #spill(): void { + const batch = this.#leaf.slice(this.#spilled) + this.#sink.spill?.(batch) + if (this.#opts.dropPayload) for (const e of batch) this.#opts.dropPayload(e) + this.#spilled = this.#leaf.length + this.#payload = 0 + } + + #emitLeaf(): void { + const entries = this.#leaf + const json = encodeLeaf(this.#spec, entries) + const desc: NodeDesc = { + level: 0, + hash: hashNode(json), + lastKey: this.#spec.key(entries[entries.length - 1]!), + count: entries.length, + bytes: this.#leafBytes, + } + this.#sink.leaf(desc, json, entries, this.#spilled) + this.#leaf = [] + this.#leafBytes = 0 + this.#spilled = 0 + this.#payload = 0 + this.#push(desc) + } + + #emitInterior(level: number): void { + const children = this.#pending[level]! + this.#pending[level] = [] + const json = encodeInterior(level, children) + let count = 0 + let bytes = 0 + for (const c of children) { + count += c.count + bytes += c.bytes + } + const desc: NodeDesc = { + level, + hash: hashNode(json), + lastKey: children[children.length - 1]!.lastKey, + count, + bytes, + } + this.#sink.interior(desc, json, children) + this.#lastBuilt[level] = { hash: desc.hash, children } + this.#push(desc) + } + + /** Record a completed level-h node and push it into level h+1. */ + #push(desc: NodeDesc): void { + if (this.#opts.leafOutput) { + this.#opts.leafOutput(desc) + return + } + const h = desc.level + this.#nodes[h] = (this.#nodes[h] ?? 0) + 1 + this.#last[h] = desc + const up = (this.#pending[h + 1] ??= []) + up.push(desc) + if (this.#chunking.ends(h + 1, boundaryBytes(desc.lastKey), up.length)) { + this.#emitInterior(h + 1) + } + } +} + +/** A sink that only collects nodes in memory: for tests, fsck and small trees. */ +export class MemorySink implements TreeSink { + readonly nodes = new Map() + readonly leaves = new Map() + leaf(desc: NodeDesc, json: string, entries: readonly E[]): void { + this.nodes.set(desc.hash, json) + this.leaves.set(desc.hash, entries.slice()) + } + interior(desc: NodeDesc, json: string): void { + this.nodes.set(desc.hash, json) + } +} + +/** Build a tree from entries already sorted by key. */ +export function buildTree( + spec: TreeSpec, + sink: TreeSink, + entries: Iterable, + opts?: BuilderOptions, +): NodeDesc | null { + const b = new TreeBuilder(spec, sink, opts) + for (const e of entries) b.addEntry(e) + // Nothing was reused, so the builder knows every node and the root is resolved. + return b.finish().root +} diff --git a/packages/protocol/src/tree/chunking.ts b/packages/protocol/src/tree/chunking.ts new file mode 100644 index 0000000..2ec03c3 --- /dev/null +++ b/packages/protocol/src/tree/chunking.ts @@ -0,0 +1,84 @@ +/** + * Where nodes end. + * + * Boundary hash: u(k) = the first 8 bytes of sha256(utf8(k)) as a big-endian + * unsigned 64-bit integer. Under the protocol rule a leaf ends after key k when + * u(k) mod 2^10 == 0 (equivalently: u(k) has at least 10 trailing zero bits), and + * a level-i node ends after a child whose last key has u mod 2^(10+6i) == 0. Each + * also ends at a forced maximum size, or at the end of its level. + * + * The rule is behind an interface only so the parameter experiments can compare + * alternatives (a size-aware chunker). The protocol has exactly one rule: + * `protocolChunking`. + */ + +import { + INTERIOR_BOUNDARY_BITS_STEP, + INTERIOR_MAX_CHILDREN, + LEAF_BOUNDARY_BITS, + LEAF_MAX_ENTRIES, +} from '../constants.js' +import { sha256 } from '../sha256.js' + +/** The first 8 bytes of sha256(utf8(key)). */ +export function boundaryBytes(key: string): Uint8Array { + return sha256(key).subarray(0, 8) +} + +/** Trailing zero bits of u(k), 0–64. */ +export function trailingZeros(u: Uint8Array): number { + let tz = 0 + for (let i = 7; i >= 0; i--) { + const b = u[i]! + if (b === 0) { + tz += 8 + continue + } + return tz + (31 - Math.clz32(b & -b)) + } + return tz +} + +export interface Chunking { + readonly name: string + /** + * Does a node at `level` end after this element? `u` is the boundary hash of the + * element's key (level 0) or of the child's last key (level ≥ 1); `size` is the + * number of elements in the node so far, including this one. + */ + ends(level: number, u: Uint8Array, size: number): boolean +} + +export interface FixedChunkingParams { + leafBits: number + stepBits: number + leafMax: number + interiorMax: number +} + +/** + * The fixed-probability rule with explicit parameters. The protocol uses exactly + * one parameter set (`protocolChunking`); others exist for tests, which need + * tiny nodes to exercise deep trees and forced splits, and for the experiments. + */ +export function fixedChunking(p: FixedChunkingParams, name = 'fixed'): Chunking { + return { + name, + ends(level, u, size) { + if (level === 0) return size >= p.leafMax || trailingZeros(u) >= p.leafBits + // Past 64 bits nothing is a natural boundary; only forced splits remain. + const bits = p.leafBits + p.stepBits * level + return size >= p.interiorMax || (bits <= 64 && trailingZeros(u) >= bits) + }, + } +} + +export const protocolChunking: Chunking = fixedChunking( + { + leafBits: LEAF_BOUNDARY_BITS, + stepBits: INTERIOR_BOUNDARY_BITS_STEP, + leafMax: LEAF_MAX_ENTRIES, + interiorMax: INTERIOR_MAX_CHILDREN, + }, + 'protocol', +) diff --git a/packages/protocol/src/tree/merge.ts b/packages/protocol/src/tree/merge.ts new file mode 100644 index 0000000..43c8c3a --- /dev/null +++ b/packages/protocol/src/tree/merge.ts @@ -0,0 +1,262 @@ +/** + * Incremental merge: apply a sorted stream of changes to a base tree. + * + * Walk the base tree top-down. A base node with no change in its key range is + * reused whole when the builder is sitting on a boundary at its level (see + * TreeBuilder.addNode); otherwise it is expanded, and at a leaf the base entries + * are merged with the changes and fed to the builder entry by entry. A change + * belongs to the first node whose last key is ≥ the change's key; changes past + * the base's last key are appended at the end. + * + * Work is O(changes × fanout × height): each change expands one path, plus at + * most one neighbour per level to re-align with the base's boundaries. The result + * is identical to building the new entry set from scratch, which the property + * tests check. + * + * Range mode (`range` plus `leafOutput`) is one commit unit of a parallel commit: + * it merges only the base entries and changes in `(after, through]` and outputs + * that range's leaves, building no interior node. When both ends are natural + * leaf boundaries present in the new entry set (or null), those leaves are + * exactly the full result's leaves for the range, because a leaf always ends + * after a natural boundary. `assembleTree` builds the levels above. + */ +import { compareUtf8 } from '../utf8.js' +import { type BuilderOptions, TreeBuilder, type TreeSink } from './builder.js' +import type { NodeDesc } from './node.js' +import { type NodeSource, resolveRoot, rootDesc } from './source.js' + +/** Upsert (`entry`) or delete (`entry: null`) of one key. */ +export interface Change { + key: string + entry: E | null +} + +export interface MergeStats { + added: number + removed: number + updated: number + /** Upserts identical to the base entry. */ + unchanged: number + /** Deletes of keys the base doesn't have. */ + missingDeletes: number + /** Base nodes reused without being read. */ + reusedNodes: number + /** Base nodes read (expanded or rewritten). */ + readNodes: number +} + +/** A key range `(after, through]`; null is unbounded on that side. */ +export interface KeyRange { + after: string | null + through: string | null +} + +export interface MergeOptions extends BuilderOptions { + /** Called for every effective change, in key order: (old, new). */ + onChange?: (before: E | null, after: E | null) => void + /** + * Merge only this range (a commit unit). Requires `leafOutput`, and every + * change must lie in the range. + */ + range?: KeyRange +} + +export interface MergeResult { + /** Null for an empty tree, and always null in range mode. */ + root: NodeDesc | null + stats: MergeStats + /** + * Range mode only: the last key output, or null if the range came out empty. + * The unit's leaves are valid only if this is `range.through` (or `through` + * is null); otherwise `through` was deleted and the caller merges this unit + * with the next one. + */ + lastKey?: string | null +} + +/** Is `key` inside `(after, through]`? */ +export function inRange(range: KeyRange, key: string): boolean { + return ( + (range.after === null || compareUtf8(key, range.after) > 0) && + (range.through === null || compareUtf8(key, range.through) <= 0) + ) +} + +export function emptyStats(): MergeStats { + return { + added: 0, + removed: 0, + updated: 0, + unchanged: 0, + missingDeletes: 0, + reusedNodes: 0, + readNodes: 0, + } +} + +export class Peekable { + readonly #it: Iterator | AsyncIterator + #head: IteratorResult | undefined + + constructor(src: Iterable | AsyncIterable) { + this.#it = + Symbol.asyncIterator in src + ? (src as AsyncIterable)[Symbol.asyncIterator]() + : (src as Iterable)[Symbol.iterator]() + } + + async peek(): Promise { + this.#head ??= await this.#it.next() + return this.#head.done ? undefined : this.#head.value + } + + async next(): Promise { + const v = await this.peek() + this.#head = undefined + return v + } +} + +export async function mergeTree( + source: NodeSource, + sink: TreeSink, + base: string | null, + changes: Iterable> | AsyncIterable>, + opts: MergeOptions = {}, +): Promise { + const spec = source.spec + const range = opts.range + if (range && !opts.leafOutput) throw new Error('mergeTree: range mode needs leafOutput') + const builder = new TreeBuilder(spec, sink, opts) + const pending = new Peekable(changes) + const stats = emptyStats() + let prevChangeKey: string | null = null + + const nextChange = async (): Promise | undefined> => { + const c = await pending.next() + if (c) { + if (prevChangeKey !== null && compareUtf8(prevChangeKey, c.key) >= 0) { + throw new Error(`mergeTree: changes out of order at ${JSON.stringify(c.key)}`) + } + if (c.entry && spec.key(c.entry) !== c.key) { + throw new Error('mergeTree: change key does not match its entry') + } + if (range && !inRange(range, c.key)) { + throw new Error(`mergeTree: change ${JSON.stringify(c.key)} is outside the unit's range`) + } + prevChangeKey = c.key + } + return c + } + + /** Apply one change that has no base entry. */ + const insert = (c: Change) => { + if (c.entry) { + stats.added++ + opts.onChange?.(null, c.entry) + builder.addEntry(c.entry) + } else { + stats.missingDeletes++ + } + } + + const rewriteLeaf = async (desc: NodeDesc) => { + stats.readNodes++ + const entries = await source.leafEntries(desc.hash) + for (const old of entries) { + const oldKey = spec.key(old) + // A leaf straddling a unit's range edge: keep only the range's entries. + if (range && !inRange(range, oldKey)) continue + // Changes before this entry are inserts (or deletes of absent keys). + for (;;) { + const c = await pending.peek() + if (!c || compareUtf8(c.key, oldKey) >= 0) break + insert((await nextChange())!) + // A run of inserts inside one base leaf's range can be arbitrarily long. + if (stats.added % 1024 === 0) await sink.drain?.() + } + const c = await pending.peek() + if (c && c.key === oldKey) { + await nextChange() + if (!c.entry) { + stats.removed++ + opts.onChange?.(old, null) + } else if (spec.same(old, c.entry)) { + stats.unchanged++ + builder.addEntry(old) + } else { + stats.updated++ + opts.onChange?.(old, c.entry) + builder.addEntry(c.entry) + } + } else { + builder.addEntry(old) + } + } + // Changes between this leaf's last entry and its last key can't exist (the + // last entry is the last key), so nothing else belongs to this leaf. + } + + // `rightEdge`: the node is the last of its level in the base. It may have ended + // because the tree ended rather than at a boundary, so it can only be reused + // when nothing is appended after it, i.e. no change remains at all. + // `low`: the node's exclusive lower bound (the previous sibling's last key, or + // null at the left edge), needed to place the node against a unit's range. + const visit = async (desc: NodeDesc, rightEdge: boolean, low: string | null): Promise => { + let inside = true + if (range) { + // Entirely before or after the range: not part of this unit. + if (range.after !== null && compareUtf8(desc.lastKey, range.after) <= 0) return + if (range.through !== null && low !== null && compareUtf8(low, range.through) >= 0) return + // Only nodes wholly inside the range may be reused, and only leaves, since + // a unit builds no interior nodes. + inside = + desc.level === 0 && + (range.after === null || (low !== null && compareUtf8(low, range.after) >= 0)) && + (range.through === null || compareUtf8(desc.lastKey, range.through) <= 0) + } + const c = await pending.peek() + const changed = c !== undefined && (rightEdge || compareUtf8(c.key, desc.lastKey) <= 0) + if (inside && !changed && builder.emptyThrough(desc.level)) { + stats.reusedNodes++ + builder.addNode(desc) + return + } + if (desc.level === 0) { + await rewriteLeaf(desc) + await sink.drain?.() + return + } + stats.readNodes++ + const node = await source.node(desc.hash) + if (node.kind !== 'node' || node.level !== desc.level) { + throw new Error(`mergeTree: node ${desc.hash} is not at level ${desc.level}`) + } + const last = node.children.length - 1 + for (let i = 0; i <= last; i++) { + await visit( + node.children[i]!, + rightEdge && i === last, + i === 0 ? low : node.children[i - 1]!.lastKey, + ) + } + } + + if (base !== null) { + if (!range && (await pending.peek()) === undefined) { + // Nothing to apply: the base is the result. + return { root: await rootDesc(source, base), stats } + } + stats.readNodes++ + await visit(await rootDesc(source, base), true, null) + } + let appended = 0 + for (let c = await nextChange(); c; c = await nextChange()) { + insert(c) + if (++appended % 1024 === 0) await sink.drain?.() + } + + const { root, unresolved } = builder.finish() + if (range) return { root: null, stats, lastKey: builder.lastKey } + return { root: root && unresolved ? await resolveRoot(source, root) : root, stats } +} diff --git a/packages/protocol/src/tree/node.ts b/packages/protocol/src/tree/node.ts new file mode 100644 index 0000000..23bf0df --- /dev/null +++ b/packages/protocol/src/tree/node.ts @@ -0,0 +1,262 @@ +/** + * Tree nodes: canonical encoding, hashing and decoding. + * + * leaf: {"e":[entry, ...],"t":"leaf"} + * interior: {"e":[[lastKey, childHash, count, bytes], ...],"l":level,"t":"node"} + * + * Both are JCS (keys sorted: "e" < "l" < "t"). A node's hash is the lowercase hex + * SHA-256 of those exact bytes. Leaf entries depend on the tree kind (TreeSpec). + */ +import { MAX_ID_BYTES } from '../constants.js' +import { sha256Hex } from '../hash.js' +import { compareUtf8, isWellFormed, utf8ByteLength } from '../utf8.js' + +const HEX64 = /^[0-9a-f]{64}$/ + +/** A child pointer as stored in an interior node, plus the child's level. */ +export interface NodeDesc { + level: number + hash: string + /** The last key under the node. Boundaries are decided on it. */ + lastKey: string + /** Entries under the node. */ + count: number + /** Sum of entry sizes under the node (TreeSpec.bytes). */ + bytes: number +} + +/** + * A tree kind: how its leaf entries are encoded. The encoding of an entry must be + * the JCS form of a JSON array, so that a node is JCS as a whole. + */ +export interface TreeSpec { + readonly name: string + key(e: E): string + /** JCS of the entry's tuple, e.g. `["id","",123]`. */ + encode(e: E): string + /** Inverse of encode, from the parsed tuple. Throws on a malformed tuple. */ + decode(tuple: unknown): E + /** Size contributed to `bytes`. */ + bytes(e: E): number + /** Same content (for change detection): same key and same value. */ + same(a: E, b: E): boolean +} + +export type DecodedNode = + | { kind: 'leaf'; hash: string; entries: E[] } + | { kind: 'node'; hash: string; level: number; children: NodeDesc[] } + +export function encodeLeaf(spec: TreeSpec, entries: readonly E[]): string { + let out = '{"e":[' + for (let i = 0; i < entries.length; i++) { + if (i > 0) out += ',' + out += spec.encode(entries[i]!) + } + return out + '],"t":"leaf"}' +} + +export function encodeInterior(level: number, children: readonly NodeDesc[]): string { + let out = '{"e":[' + for (let i = 0; i < children.length; i++) { + const c = children[i]! + if (i > 0) out += ',' + out += '[' + JSON.stringify(c.lastKey) + ',"' + c.hash + '",' + c.count + ',' + c.bytes + ']' + } + return out + '],"l":' + level + ',"t":"node"}' +} + +export function hashNode(json: string): string { + return sha256Hex(json) +} + +export class NodeFormatError extends Error { + constructor(message: string) { + super(message) + this.name = 'NodeFormatError' + } +} + +const isCount = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) >= 0 + +/** + * Decode a node from its stored JSON and check it: the bytes hash to `expectedHash` + * (when given), the shape is right for the tree kind, and the bytes are canonical + * (re-encoding gives the same string). A non-canonical encoding of the same content + * would have a different hash, so accepting one would let two hashes name one node. + * + * Child levels can't be checked here: an interior node's children are one level + * below it, which the decoder records; the reader checks it when it loads them. + */ +export function decodeNode( + spec: TreeSpec, + json: string, + expectedHash?: string, +): DecodedNode { + const hash = hashNode(json) + if (expectedHash !== undefined && hash !== expectedHash) { + throw new NodeFormatError(`Node hash mismatch: expected ${expectedHash}, got ${hash}`) + } + let parsed: unknown + try { + parsed = JSON.parse(json) + } catch { + throw new NodeFormatError('Node is not valid JSON') + } + const obj = parsed as { t?: unknown; e?: unknown; l?: unknown } + if (!obj || typeof obj !== 'object' || !Array.isArray(obj.e) || obj.e.length === 0) { + throw new NodeFormatError('Node must have a non-empty "e" array') + } + if (obj.t === 'leaf') { + if (Object.keys(obj).length !== 2) throw new NodeFormatError('Leaf has unexpected fields') + const entries = obj.e.map((t) => spec.decode(t)) + for (let i = 1; i < entries.length; i++) { + if (compareUtf8(spec.key(entries[i - 1]!), spec.key(entries[i]!)) >= 0) { + throw new NodeFormatError('Leaf keys are not strictly increasing') + } + } + if (encodeLeaf(spec, entries) !== json) throw new NodeFormatError('Leaf is not canonical') + return { kind: 'leaf', hash, entries } + } + if (obj.t === 'node') { + if (Object.keys(obj).length !== 3) throw new NodeFormatError('Node has unexpected fields') + const level = obj.l + if (!Number.isSafeInteger(level) || (level as number) < 1) { + throw new NodeFormatError('Interior node level must be an integer ≥ 1') + } + const children: NodeDesc[] = obj.e.map((t) => { + if (!Array.isArray(t) || t.length !== 4) throw new NodeFormatError('Bad interior entry') + const [lastKey, h, count, bytes] = t as unknown[] + if (typeof lastKey !== 'string' || typeof h !== 'string' || !HEX64.test(h)) { + throw new NodeFormatError('Bad interior entry') + } + if (!isCount(count) || count < 1 || !isCount(bytes)) { + throw new NodeFormatError('Bad interior entry counts') + } + return { level: (level as number) - 1, hash: h, lastKey, count, bytes } + }) + for (let i = 1; i < children.length; i++) { + if (compareUtf8(children[i - 1]!.lastKey, children[i]!.lastKey) >= 0) { + throw new NodeFormatError('Interior keys are not strictly increasing') + } + } + if (encodeInterior(level as number, children) !== json) { + throw new NodeFormatError('Interior node is not canonical') + } + return { kind: 'node', hash, level: level as number, children } + } + throw new NodeFormatError('Node "t" must be "leaf" or "node"') +} + +/** The descriptor of a decoded node, as its parent would hold it. */ +export function describeNode(spec: TreeSpec, node: DecodedNode): NodeDesc { + if (node.kind === 'leaf') { + let bytes = 0 + for (const e of node.entries) bytes += spec.bytes(e) + return { + level: 0, + hash: node.hash, + lastKey: spec.key(node.entries[node.entries.length - 1]!), + count: node.entries.length, + bytes, + } + } + let count = 0 + let bytes = 0 + for (const c of node.children) { + count += c.count + bytes += c.bytes + } + return { + level: node.level, + hash: node.hash, + lastKey: node.children[node.children.length - 1]!.lastKey, + count, + bytes, + } +} + +// --- Tree kinds --------------------------------------------------------------- + +/** + * Record trees: key = record id, value = record hash, plus the canonical record's + * size in bytes. `body` (the canonical record line) travels with an entry while + * it is being written or read; it is never part of the node. + */ +export interface RecordEntry { + key: string + hash: string + size: number + body?: string +} + +export const recordTree: TreeSpec = { + name: 'record', + key: (e) => e.key, + encode: (e) => '[' + JSON.stringify(e.key) + ',"' + e.hash + '",' + e.size + ']', + decode(t) { + if (!Array.isArray(t) || t.length !== 3) throw new NodeFormatError('Bad record entry') + const [key, hash, size] = t as unknown[] + if ( + typeof key !== 'string' || + typeof hash !== 'string' || + !HEX64.test(hash) || + !isCount(size) + ) { + throw new NodeFormatError('Bad record entry') + } + // A received node's ids follow the input rules too (docs/protocol-v2.md §3, §8.3). + if (key === '' || utf8ByteLength(key) > MAX_ID_BYTES) + throw new NodeFormatError('Bad record entry: the id is empty or over the id length limit') + if (!isWellFormed(key)) throw new NodeFormatError('Bad record entry: lone surrogate in an id') + return { key, hash, size } + }, + bytes: (e) => e.size, + same: (a, b) => a.key === b.key && a.hash === b.hash, +} + +/** File trees: key = file hash (hex), value = file size in bytes. */ +export interface FileEntry { + key: string + size: number +} + +export const fileTree: TreeSpec = { + name: 'file', + key: (e) => e.key, + encode: (e) => '["' + e.key + '",' + e.size + ']', + decode(t) { + if (!Array.isArray(t) || t.length !== 2) throw new NodeFormatError('Bad file entry') + const [key, size] = t as unknown[] + if (typeof key !== 'string' || !HEX64.test(key) || !isCount(size)) { + throw new NodeFormatError('Bad file entry') + } + return { key, size } + }, + bytes: (e) => e.size, + same: (a, b) => a.key === b.key && a.size === b.size, +} + +/** + * Not protocol: a sidecar tree of reference counts (key = file hash, value = + * number of references from a set's records). Lets a commit keep file sets + * correct in O(changes). Same tree code, so the same reuse and caching. + */ +export interface CountEntry { + key: string + n: number +} + +export const countTree: TreeSpec = { + name: 'count', + key: (e) => e.key, + encode: (e) => '[' + JSON.stringify(e.key) + ',' + e.n + ']', + decode(t) { + if (!Array.isArray(t) || t.length !== 2) throw new NodeFormatError('Bad count entry') + const [key, n] = t as unknown[] + if (typeof key !== 'string' || !isCount(n) || n < 1) + throw new NodeFormatError('Bad count entry') + return { key, n } + }, + bytes: () => 0, + same: (a, b) => a.key === b.key && a.n === b.n, +} diff --git a/packages/protocol/src/tree/read.ts b/packages/protocol/src/tree/read.ts new file mode 100644 index 0000000..b6a233d --- /dev/null +++ b/packages/protocol/src/tree/read.ts @@ -0,0 +1,345 @@ +/** + * Reading trees: point lookups, ordered iteration from a key or an offset, and + * diff. Interior entries carry each child's last key and entry count, so a seek by + * key or by offset reads one node per level. + */ +import { compareUtf8 } from '../utf8.js' +import type { NodeDesc } from './node.js' +import { type NodeSource, rootDesc } from './source.js' + +/** Look up one key. One node read per level. */ +export async function getEntry( + source: NodeSource, + root: string | null, + key: string, +): Promise { + if (root === null) return null + let hash = root + for (;;) { + const node = await source.node(hash) + if (node.kind === 'leaf') { + return node.entries.find((e) => source.spec.key(e) === key) ?? null + } + const child = node.children.find((c) => compareUtf8(key, c.lastKey) <= 0) + if (!child) return null + hash = child.hash + } +} + +/** + * The number of entries with keys less than `key` (its rank). One node read per + * level, using the counts in interior entries. + */ +export async function rankOf( + source: NodeSource, + root: string | null, + key: string, +): Promise { + if (root === null) return 0 + let rank = 0 + let hash = root + for (;;) { + const node = await source.node(hash) + if (node.kind === 'leaf') { + for (const e of node.entries) { + if (compareUtf8(source.spec.key(e), key) >= 0) break + rank++ + } + return rank + } + let next: string | null = null + for (const c of node.children) { + if (compareUtf8(key, c.lastKey) <= 0) { + next = c.hash + break + } + rank += c.count + } + if (next === null) return rank + hash = next + } +} + +/** The entry at a position (0-based), or null past the end. One node read per level. */ +export async function entryAt( + source: NodeSource, + root: string | null, + index: number, +): Promise { + if (root === null || index < 0) return null + let hash = root + let i = index + for (;;) { + const node = await source.node(hash) + if (node.kind === 'leaf') return node.entries[i] ?? null + let next: string | null = null + for (const c of node.children) { + if (i < c.count) { + next = c.hash + break + } + i -= c.count + } + if (next === null) return null + hash = next + } +} + +export interface IterateOptions { + /** Start after this key (keyset pagination). Exclusive with `offset`. */ + after?: string + /** Skip this many entries first (offset pagination; O(height) via counts). Exclusive with `after`. */ + offset?: number + /** Leaves to fetch ahead in parallel. Default 4. */ + prefetch?: number + /** Read leaves with payloads (record bodies) through `leafEntries`. */ + payloads?: boolean +} + +/** + * The leaves covering a seek, in order: yields each leaf's descriptor and how + * many of its entries to skip. Interior nodes are read on demand, one path at a + * time. + */ +async function* leavesFrom( + source: NodeSource, + root: NodeDesc, + after: string | undefined, + offset: number, +): AsyncGenerator<{ leaf: NodeDesc; skip: number; after: string | undefined }> { + // Stack of (children, next index) frames, rooted at the root. + type Frame = { children: NodeDesc[]; i: number } + const stack: Frame[] = [{ children: [root], i: 0 }] + let first = true + let remaining = offset + while (stack.length > 0) { + const top = stack[stack.length - 1]! + if (top.i >= top.children.length) { + stack.pop() + continue + } + const d = top.children[top.i++]! + if (first) { + // Still seeking: skip whole subtrees before the start position. + if (after !== undefined && compareUtf8(d.lastKey, after) <= 0) continue + if (remaining >= d.count) { + remaining -= d.count + continue + } + } + if (d.level === 0) { + yield { leaf: d, skip: first ? remaining : 0, after: first ? after : undefined } + first = false + continue + } + const node = await source.node(d.hash) + if (node.kind !== 'node') throw new Error(`Expected interior node at ${d.hash}`) + stack.push({ children: node.children, i: 0 }) + } +} + +/** Every leaf of a tree, in key order (interior nodes read one path at a time). */ +export async function* leaves( + source: NodeSource, + root: string | null, +): AsyncGenerator { + if (root === null) return + for await (const { leaf } of leavesFrom(source, await rootDesc(source, root), undefined, 0)) + yield leaf +} + +/** Iterate entries in key order from a position, prefetching leaves ahead. */ +export async function* iterate( + source: NodeSource, + root: string | null, + opts: IterateOptions = {}, +): AsyncGenerator { + if (root === null) return + if (opts.after !== undefined && opts.offset) + throw new Error('iterate: pass after or offset, not both') + const spec = source.spec + const load = (h: string) => + opts.payloads + ? source.leafEntries(h) + : source.node(h).then((n) => (n.kind === 'leaf' ? n.entries : [])) + const ahead = Math.max(1, opts.prefetch ?? 4) + const queue: { entries: Promise; skip: number; after: string | undefined }[] = [] + const leaves = leavesFrom(source, await rootDesc(source, root), opts.after, opts.offset ?? 0) + let exhausted = false + const fill = async () => { + while (!exhausted && queue.length < ahead) { + const r = await leaves.next() + if (r.done) { + exhausted = true + break + } + const entries = load(r.value.leaf.hash) + entries.catch(() => {}) // surfaced when awaited below + queue.push({ entries, skip: r.value.skip, after: r.value.after }) + } + } + for (;;) { + await fill() + const next = queue.shift() + if (!next) return + const entries = await next.entries + let i = 0 + if (next.after !== undefined) { + while (i < entries.length && compareUtf8(spec.key(entries[i]!), next.after) <= 0) i++ + } + i += next.skip + for (; i < entries.length; i++) yield entries[i]! + } +} + +export interface DiffEntry { + key: string + /** Entry in the first tree, or null when added. */ + before: E | null + /** Entry in the second tree, or null when removed. */ + after: E | null +} + +type Item = { node: NodeDesc } | { entry: E } + +/** + * Entries that differ between two trees, in key order. Subtrees with equal hashes + * hold identical entries and are skipped without being read; because node + * boundaries depend only on keys, unchanged regions line up and the walk costs + * O(changes × height) node reads. + * + * `after` resumes past a key: subtrees that end at or before it are skipped + * unread, so a page of a long diff costs that page, not everything before it. + */ +export async function* diffTrees( + source: NodeSource, + a: string | null, + b: string | null, + opts: { after?: string | undefined } = {}, +): AsyncGenerator> { + const spec = source.spec + const after = opts.after + /** Drop leading items wholly at or before `after`. */ + const prune = (items: Item[]) => { + if (after === undefined) return + while (items.length > 0) { + const head = items[0]! + const last = 'node' in head ? head.node.lastKey : spec.key(head.entry) + if (compareUtf8(last, after) > 0) return + items.shift() + } + } + const xs: Item[] = a === null ? [] : [{ node: await rootDesc(source, a) }] + const ys: Item[] = b === null ? [] : [{ node: await rootDesc(source, b) }] + const expand = async (items: Item[]) => { + const head = items.shift() as { node: NodeDesc } + const n = await source.node(head.node.hash) + const children: Item[] = + n.kind === 'leaf' + ? n.entries.map((entry) => ({ entry })) + : n.children.map((node) => ({ node })) + items.unshift(...children) + } + for (;;) { + prune(xs) + prune(ys) + const x = xs[0] + const y = ys[0] + if (!x && !y) return + if (x && 'node' in x && y && 'node' in y && x.node.hash === y.node.hash) { + xs.shift() + ys.shift() + continue + } + if (x && 'node' in x && (!y || 'entry' in y || x.node.level >= y.node.level)) { + await expand(xs) + continue + } + if (y && 'node' in y) { + await expand(ys) + continue + } + // Both heads are entries (or one side is exhausted). + const ex = x && 'entry' in x ? x.entry : undefined + const ey = y && 'entry' in y ? y.entry : undefined + if (ex !== undefined && (ey === undefined || compareUtf8(spec.key(ex), spec.key(ey)) < 0)) { + xs.shift() + yield { key: spec.key(ex), before: ex, after: null } + } else if ( + ey !== undefined && + (ex === undefined || compareUtf8(spec.key(ey), spec.key(ex)) < 0) + ) { + ys.shift() + yield { key: spec.key(ey), before: null, after: ey } + } else { + xs.shift() + ys.shift() + if (!spec.same(ex!, ey!)) yield { key: spec.key(ex!), before: ex!, after: ey! } + } + } +} + +/** + * The nodes of tree `b` that tree `a` doesn't have at the same position, top + * down (a parent before its children). The walk is `diffTrees`' and costs the + * same: equal subtrees are skipped unread. This is what a sync sends. + * + * `after` resumes a walk that stopped after the leaf whose last key it is: only + * nodes whose keys all come after it are listed (the ones spanning it were + * listed before their children, so before the stop). + */ +export async function* newNodes( + source: NodeSource, + a: string | null, + b: string | null, + opts: { after?: string | null } = {}, +): AsyncGenerator { + if (b === null || a === b) return + const spec = source.spec + const after = opts.after ?? null + type Side = ({ node: NodeDesc; low: string | null } | { entry: E })[] + const xs: Side = a === null ? [] : [{ node: await rootDesc(source, a), low: null }] + const ys: Side = [{ node: await rootDesc(source, b), low: null }] + const expand = async (items: Side) => { + const head = items.shift() as { node: NodeDesc; low: string | null } + const n = await source.node(head.node.hash) + let low = head.low + const children: Side = [] + if (n.kind === 'leaf') for (const entry of n.entries) children.push({ entry }) + else + for (const node of n.children) { + children.push({ node, low }) + low = node.lastKey + } + items.unshift(...children) + } + // Items entirely at or before the cursor are done. + const done = (it: Side[number]) => + after !== null && compareUtf8('node' in it ? it.node.lastKey : spec.key(it.entry), after) <= 0 + for (;;) { + while (xs[0] && done(xs[0])) xs.shift() + while (ys[0] && done(ys[0])) ys.shift() + const x = xs[0] + const y = ys[0] + if (!y) return + if (x && 'node' in x && 'node' in y && x.node.hash === y.node.hash) { + xs.shift() + ys.shift() + continue + } + if (x && 'node' in x && ('entry' in y || x.node.level >= y.node.level)) { + await expand(xs) + continue + } + if ('node' in y) { + if (after === null || (y.low !== null && compareUtf8(y.low, after) >= 0)) yield y.node + await expand(ys) + continue + } + // Both heads are entries: keep the two sides aligned by key. + const ky = spec.key(y.entry) + const c = x && 'entry' in x ? compareUtf8(spec.key(x.entry), ky) : 1 + if (c <= 0) xs.shift() + if (c >= 0) ys.shift() + } +} diff --git a/packages/protocol/src/tree/source.ts b/packages/protocol/src/tree/source.ts new file mode 100644 index 0000000..8c66b6d --- /dev/null +++ b/packages/protocol/src/tree/source.ts @@ -0,0 +1,58 @@ +/** + * Where tree readers get nodes from. The server backs this with the blob store + * plus caches; tests use a Map. + */ +import { type DecodedNode, decodeNode, describeNode, type NodeDesc, type TreeSpec } from './node.js' + +export interface NodeSource { + readonly spec: TreeSpec + /** Load and verify a node. */ + node(hash: string): Promise> + /** + * A leaf's entries with their payloads (record bodies), for rewriting a leaf. + * Sources whose entries carry no payload return the node's entries. + */ + leafEntries(hash: string): Promise +} + +/** A source over node JSON held in memory, keyed by hash. */ +export class MapSource implements NodeSource { + constructor( + readonly spec: TreeSpec, + readonly nodes: Map, + readonly payloads?: Map, + ) {} + + async node(hash: string): Promise> { + const json = this.nodes.get(hash) + if (json === undefined) throw new Error(`Node ${hash} not found`) + return decodeNode(this.spec, json, hash) + } + + async leafEntries(hash: string): Promise { + const withPayload = this.payloads?.get(hash) + if (withPayload) return withPayload.slice() + const n = await this.node(hash) + if (n.kind !== 'leaf') throw new Error(`Node ${hash} is not a leaf`) + return n.entries + } +} + +/** The full descriptor of a tree's root (level and last key come from the node). */ +export async function rootDesc(source: NodeSource, hash: string): Promise { + return describeNode(source.spec, await source.node(hash)) +} + +/** + * Finish `TreeBuilder.finish` when it reports `unresolved`: walk down while the + * node has a single child. See the builder for why. + */ +export async function resolveRoot(source: NodeSource, desc: NodeDesc): Promise { + let d = desc + while (d.level > 0) { + const n = await source.node(d.hash) + if (n.kind !== 'node' || n.children.length !== 1) break + d = n.children[0]! + } + return d +} diff --git a/packages/protocol/src/tree/verify.ts b/packages/protocol/src/tree/verify.ts new file mode 100644 index 0000000..ea8004f --- /dev/null +++ b/packages/protocol/src/tree/verify.ts @@ -0,0 +1,89 @@ +/** + * fsck for trees: validate structure, not just hashes. + * + * A node can hash correctly and still break the rules: keys out of order, a + * boundary in the wrong place, wrong counts, wrong levels. Such a tree gives a + * valid-looking but different root for the same entries, which breaks + * convergence. `verifyTree` checks every node against its parent's pointer and + * then rebuilds the tree from its entries; the rebuilt root must be the same + * hash, which covers every chunking rule at once. + */ +import { compareUtf8 } from '../utf8.js' +import { TreeBuilder } from './builder.js' +import { type Chunking } from './chunking.js' +import { describeNode, type NodeDesc } from './node.js' +import type { NodeSource } from './source.js' + +export interface VerifyResult { + ok: boolean + errors: string[] + count: number + bytes: number + nodes: number +} + +export async function verifyTree( + source: NodeSource, + root: string | null, + opts: { chunking?: Chunking; maxErrors?: number } = {}, +): Promise { + const result: VerifyResult = { ok: true, errors: [], count: 0, bytes: 0, nodes: 0 } + if (root === null) return result + const maxErrors = opts.maxErrors ?? 20 + const fail = (msg: string) => { + result.ok = false + if (result.errors.length < maxErrors) result.errors.push(msg) + } + const spec = source.spec + const rebuilt = new TreeBuilder( + spec, + { leaf() {}, interior() {} }, + opts.chunking ? { chunking: opts.chunking } : {}, + ) + let prev: string | null = null + + const walk = async (hash: string, expect: NodeDesc | null): Promise => { + let node + try { + node = await source.node(hash) + } catch (err) { + fail(`${hash}: ${(err as Error).message}`) + return + } + result.nodes++ + const actual = describeNode(spec, node) + if (expect) { + if (actual.level !== expect.level) + fail(`${hash}: level ${actual.level}, parent says ${expect.level}`) + if (actual.lastKey !== expect.lastKey) fail(`${hash}: last key differs from parent pointer`) + if (actual.count !== expect.count) + fail(`${hash}: count ${actual.count}, parent says ${expect.count}`) + if (actual.bytes !== expect.bytes) + fail(`${hash}: bytes ${actual.bytes}, parent says ${expect.bytes}`) + } + if (node.kind === 'leaf') { + for (const e of node.entries) { + const k = spec.key(e) + if (prev !== null && compareUtf8(prev, k) >= 0) { + fail(`${hash}: key ${JSON.stringify(k)} out of order`) + continue + } + prev = k + result.count++ + result.bytes += spec.bytes(e) + rebuilt.addEntry(e) + } + return + } + for (const child of node.children) await walk(child.hash, child) + } + + await walk(root, null) + if (result.ok) { + const { root: again } = rebuilt.finish() + if (again?.hash !== root) { + fail(`Tree is not canonical: rebuilding its entries gives root ${again?.hash ?? 'null'}`) + } + } + return result +} diff --git a/packages/protocol/src/utf8.ts b/packages/protocol/src/utf8.ts new file mode 100644 index 0000000..e25e2c9 --- /dev/null +++ b/packages/protocol/src/utf8.ts @@ -0,0 +1,63 @@ +const encoder = new TextEncoder() + +export function utf8(s: string): Uint8Array { + return encoder.encode(s) +} + +/** No lone UTF-16 surrogates: every high surrogate is followed by a low one, and vice versa. */ +export function isWellFormed(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const u = s.charCodeAt(i) + if (u < 0xd800 || u > 0xdfff) continue + if (u > 0xdbff) return false + const next = s.charCodeAt(i + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) return false + i++ + } + return true +} + +/** UTF-8 byte length of a well-formed string, without encoding it. */ +export function utf8ByteLength(s: string): number { + let bytes = 0 + for (let i = 0; i < s.length; i++) { + const u = s.charCodeAt(i) + if (u < 0x80) bytes += 1 + else if (u < 0x800) bytes += 2 + else if (u >= 0xd800 && u <= 0xdbff && i + 1 < s.length) { + // A surrogate pair is one 4-byte code point. + bytes += 4 + i++ + } else bytes += 3 + } + return bytes +} + +/** + * Compare two strings by their UTF-8 bytes, which is Unicode code point order. + * + * JavaScript's `<` and default `sort()` compare UTF-16 code units, and those + * disagree with code point order exactly when one side has a surrogate + * (U+10000 and up, stored as 0xD800–0xDFFF) and the other a code unit in + * 0xE000–0xFFFF. At the first differing unit, remap so surrogates sort above the + * rest of the BMP; everything else compares as is. + */ +export function compareUtf8(a: string, b: string): number { + if (a === b) return 0 + const n = Math.min(a.length, b.length) + for (let i = 0; i < n; i++) { + const x = a.charCodeAt(i) + const y = b.charCodeAt(i) + if (x !== y) { + if (x >= 0xd800 && y >= 0xd800) return fixup(x) - fixup(y) + return x - y + } + } + return a.length - b.length +} + +function fixup(u: number): number { + if (u >= 0xe000) return u - 0x800 + if (u >= 0xd800) return u + 0x2000 + return u +} diff --git a/packages/protocol/src/validate.ts b/packages/protocol/src/validate.ts new file mode 100644 index 0000000..fd38e6d --- /dev/null +++ b/packages/protocol/src/validate.ts @@ -0,0 +1,855 @@ +/** + * Record validation against type schemas (protocol v2, section 5). + * + * v1 validated with AJV, which compiles every schema to JavaScript with + * `new Function`. Cloudflare Workers forbid that, so v2 uses + * @cfworker/json-schema, which interprets the schema and never evaluates code. + * The dialect is v1's, and scripts/diff-validators.ts checks the two agree on + * every production schema and record: + * + * - JSON Schema draft-07. A root `$schema`, if present, must name draft-07. + * - Keywords next to `$ref` are applied, as AJV does (draft-07 says to ignore them). + * - Keywords draft-07 doesn't define are ignored. That includes later drafts' + * keywords, which @cfworker/json-schema would otherwise enforce in any draft. + * - `format` constrains strings only, with ajv-formats' definitions (see + * AJV_FORMATS below), kept apart from the library's own table. Unknown + * formats are ignored. + * - A schema must itself be valid against the draft-07 meta-schema, every + * `pattern` must be a valid Unicode (`u` flag) regex, and every `$ref` must + * resolve inside the schema; all three are checked when it is compiled. + */ +import { + deepCompareStrict, + dereference, + format as formats, + validate as interpret, + type OutputUnit, + type Schema, +} from '@cfworker/json-schema' + +import { MAX_PATTERN_LENGTH, MAX_SCHEMA_BYTES } from './constants.js' +import { hashSchema } from './hash.js' +import { checkTypeSlug } from './input-rules.js' +import { jcs } from './jcs.js' +import { utf8ByteLength } from './utf8.js' + +const MAX_CACHED_VALIDATORS = 500 + +/** A schema that can't be compiled: not valid draft-07, or an unresolvable `$ref`. */ +export class SchemaError extends Error { + constructor(message: string) { + super(message) + this.name = 'SchemaError' + } +} + +/** Validate one record's `data`. Returns `" "` strings; empty means valid. */ +export type SchemaValidator = (data: unknown) => string[] + +type Lookup = Record + +// Fixed rather than the library default, which is the page URL in a browser: +// relative `$id`s and `$ref`s must resolve the same everywhere. +const BASE_URI = new URL('https://schema.underlay.invalid/') + +const META_ID = 'http://json-schema.org/draft-07/schema' +const DRAFT_07 = new Set([ + 'http://json-schema.org/draft-07/schema', + 'http://json-schema.org/draft-07/schema#', +]) + +// Keywords whose values are maps from a name to a subschema: the names are data, +// not keywords, so a property called "prefixItems" must survive `toDialect`. +const MAP_KEYWORDS = new Set([ + 'properties', + 'patternProperties', + 'definitions', + '$defs', + 'dependencies', +]) +// Keywords whose values are instance data, never schemas. +const DATA_KEYWORDS = new Set(['enum', 'const', 'default', 'examples', 'required', 'type']) +// Keywords @cfworker/json-schema implements but draft-07 (and so AJV) ignores. +// `id` is draft-04's `$id`; the library honours it, AJV's draft-07 does not. +const NOT_DRAFT_07 = [ + 'id', + '$recursiveRef', + '$recursiveAnchor', + 'unevaluatedProperties', + 'unevaluatedItems', + 'dependentRequired', + 'dependentSchemas', + 'prefixItems', + 'minContains', + 'maxContains', +] + +const isObject = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v) + +// --- Compile ------------------------------------------------------------------ + +// Schemas are content-addressed, so compiled validators are keyed by schema +// hash: every push parses its schemas afresh, and identity would never hit. +const validatorCache = new Map() + +/** + * Compile a type schema, reusing the validator for identical schema content. + * Throws SchemaError, and nothing else, if the schema is not valid draft-07 or + * a `$ref` doesn't resolve. Run `checkSchema` first: this does not bound size + * or patterns. `checkSchemaFull` runs both. + */ +export function compileSchema(schema: unknown): SchemaValidator { + let key: string + let validator: SchemaValidator + try { + key = hashSchema(schema) + const cached = validatorCache.get(key) + if (cached) return cached + validator = build(schema) + } catch (err) { + // Every compile step recurses over the schema; a deeply nested one + // overflows the stack, which is a bad schema, not a server error. + if (err instanceof SchemaError) throw err + throw new SchemaError(`schema could not be compiled: ${(err as Error).message}`) + } + if (validatorCache.size >= MAX_CACHED_VALIDATORS) { + const oldest = validatorCache.keys().next().value + if (oldest !== undefined) validatorCache.delete(oldest) + } + validatorCache.set(key, validator) + return validator +} + +function build(schema: unknown): SchemaValidator { + // Boolean schemas are fine below the root, but a type's schema is an object: + // v1 could not compile `true` or `false` there either. + if (!isObject(schema)) throw new SchemaError('schema must be an object') + if (schema.$schema !== undefined && !DRAFT_07.has(schema.$schema as string)) { + throw new SchemaError( + `unsupported $schema ${JSON.stringify(schema.$schema)}: schemas must be JSON Schema draft-07`, + ) + } + // A bare copy: the library tests membership with `in`, and annotates the + // schema objects it is given. + const body = bare(schema) as Schema + registerFormats() + const meta = interpret(body, META_SCHEMA, '7', META_LOOKUP, true) + if (!meta.valid) { + throw new SchemaError( + `schema is invalid: ${messages(meta.errors, META_SCHEMA, META_LOOKUP, body).join(', ')}`, + ) + } + // The meta-schema's `regex` format accepts what `new RegExp(p)` accepts, but + // patterns run with the `u` flag (as in AJV), which is stricter. + const badPattern = findBadPattern(body) + if (badPattern) throw new SchemaError(badPattern) + + toDialect(body) + // Seeded with the meta-schema, which AJV also resolves `$ref`s to. + const lookup: Lookup = Object.assign(Object.create(null) as Lookup, META_LOOKUP) + try { + dereference(body, lookup, BASE_URI) + } catch (err) { + throw new SchemaError((err as Error).message) + } + // The library only finds a dangling `$ref` when a record reaches it; AJV + // refused the schema up front, and so does v2. + for (const sub of Object.values(lookup)) { + if (typeof sub === 'object' && typeof sub.$ref === 'string') { + if (lookup[sub.__absolute_ref__ ?? sub.$ref] === undefined) { + throw new SchemaError(`can't resolve reference ${sub.$ref}`) + } + } + } + + // Short-circuiting stops a `properties` or `items` loop at its first failure. + // It never changes the verdict, only how many errors come back, so it is off + // where it is safe: AJV (v1) reported every error. It is unsafe in schemas + // with keywords whose failing branches are explored and thrown away: each + // such branch re-validates its whole subtree, which is exponential in depth + // on recursive schemas. A 567 KB production record under a recursive `oneOf` + // schema took 1.5 ms short-circuited and did not finish otherwise (AJV with + // allErrors, as v1 ran it, took 7 s). A `$ref` to the meta-schema brings + // its `anyOf`s in. + const refsMeta = Object.entries(lookup).some( + ([uri, sub]) => + !uri.startsWith(META_ID) && + typeof sub === 'object' && + !!sub.__absolute_ref__?.startsWith(META_ID), + ) + const shortCircuit = refsMeta || hasBranching(body) + + return (data) => { + try { + const instance = bare(data) + // '2019-09' only changes `$ref` handling here (siblings are applied); the + // later-draft keywords it would add were removed by `toDialect`. + const result = interpret(instance, body, '2019-09', lookup, shortCircuit) + if (result.valid) return [] + // The verdict is the library's. A message we fail to word must never turn + // an invalid record into a valid one. + const out = messages(result.errors, body, lookup, instance) + return out.length > 0 ? out : ['/ must match the schema'] + } catch (err) { + // A schema that recurses without consuming data, e.g. `{"$ref":"#"}`, + // overflows the stack. Report it against the record, don't crash the push. + return [`/ schema could not be applied: ${(err as Error).message}`] + } + } +} + +/** + * A deep copy whose objects have no prototype. The library tests membership + * with `in`, so on ordinary objects `required: ["toString"]` would pass for `{}` + * and `properties: {"constructor": …}` would be checked against `Object`. + */ +function bare(value: unknown): unknown { + if (Array.isArray(value)) return value.map(bare) + if (value === null || typeof value !== 'object') return value + const out = Object.create(null) as Record + for (const [k, v] of Object.entries(value)) out[k] = bare(v) + return out +} + +const BRANCHING = new Set(['anyOf', 'oneOf', 'not', 'if', 'contains']) + +/** Whether a schema uses a keyword that validates and discards a failing branch. */ +function hasBranching(node: unknown): boolean { + if (Array.isArray(node)) return node.some(hasBranching) + if (!isObject(node)) return false + for (const [k, v] of Object.entries(node)) { + if (DATA_KEYWORDS.has(k)) continue + if (BRANCHING.has(k)) return true + if ( + MAP_KEYWORDS.has(k) && isObject(v) ? Object.values(v).some(hasBranching) : hasBranching(v) + ) { + return true + } + } + return false +} + +/** + * Remove, in place, the keywords @cfworker/json-schema honours but draft-07 + * doesn't, and point formats at the dialect's definitions. + */ +function toDialect(node: unknown): void { + if (Array.isArray(node)) { + for (const item of node) toDialect(item) + return + } + if (!isObject(node)) return + for (const k of NOT_DRAFT_07) delete node[k] + namespaceFormat(node) + for (const [k, v] of Object.entries(node)) { + if (DATA_KEYWORDS.has(k)) continue + if (MAP_KEYWORDS.has(k) && isObject(v)) { + for (const sub of Object.values(v)) toDialect(sub) + } else { + toDialect(v) + } + } +} + +function findBadPattern(node: unknown): string | null { + if (Array.isArray(node)) { + for (const item of node) { + const found = findBadPattern(item) + if (found) return found + } + return null + } + if (!isObject(node)) return null + const patterns: unknown[] = [node.pattern] + if (isObject(node.patternProperties)) patterns.push(...Object.keys(node.patternProperties)) + for (const p of patterns) { + if (typeof p !== 'string') continue + try { + new RegExp(p, 'u') + } catch (err) { + return `pattern ${JSON.stringify(p)} is invalid: ${(err as Error).message}` + } + } + for (const [k, v] of Object.entries(node)) { + if (DATA_KEYWORDS.has(k)) continue + const found = + MAP_KEYWORDS.has(k) && isObject(v) ? findBadPattern(Object.values(v)) : findBadPattern(v) + if (found) return found + } + return null +} + +// --- Formats ------------------------------------------------------------------ +// +// v1 used ajv-formats in "full" mode. @cfworker/json-schema's formats agree with +// it on date, uri, uri-reference, uri-template, url, uuid, hostname, ipv6 and the +// JSON pointer formats, but differ on the ones below, so these are ajv-formats' +// definitions (MIT, https://github.com/ajv-validator/ajv-formats), installed in +// the library's format table. ajv-formats' number formats (int32, int64, float, +// double) are not carried over: the library applies `format` to strings only. +// No production schema uses them. + +const isLeapYear = (y: number) => y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) +const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/ +const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] +function date(str: string): boolean { + const m = DATE.exec(str) + if (!m) return false + const year = +m[1]! + const month = +m[2]! + const day = +m[3]! + return ( + month >= 1 && + month <= 12 && + day >= 1 && + day <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]!) + ) +} + +const TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i +function time(strictTimeZone: boolean) { + return (str: string): boolean => { + const m = TIME.exec(str) + if (!m) return false + const hr = +m[1]! + const min = +m[2]! + const sec = +m[3]! + const tz = m[4] + const tzSign = m[5] === '-' ? -1 : 1 + const tzH = +(m[6] || 0) + const tzM = +(m[7] || 0) + if (tzH > 23 || tzM > 59 || (strictTimeZone && !tz)) return false + if (hr <= 23 && min <= 59 && sec < 60) return true + // A leap second is valid only at 23:59:60 UTC. + const utcMin = min - tzM * tzSign + const utcHr = hr - tzH * tzSign - (utcMin < 0 ? 1 : 0) + return (utcHr === 23 || utcHr === -1) && (utcMin === 59 || utcMin === -1) && sec < 61 + } +} + +const DATE_TIME_SEPARATOR = /t|\s/i +function dateTime(strictTimeZone: boolean) { + const t = time(strictTimeZone) + return (str: string): boolean => { + const parts = str.split(DATE_TIME_SEPARATOR) + return parts.length === 2 && date(parts[0]!) && t(parts[1]!) + } +} + +const Z_ANCHOR = /[^\\]\\Z/ +function regex(str: string): boolean { + if (Z_ANCHOR.test(str)) return false + try { + new RegExp(str) + return true + } catch { + return false + } +} + +const matches = (re: RegExp) => (str: string) => re.test(str) + +const AJV_FORMATS: Record boolean> = { + date, + time: time(true), + 'date-time': dateTime(true), + 'iso-time': time(false), + 'iso-date-time': dateTime(false), + duration: matches(/^P(?!$)((\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?|(\d+W)?)$/), + email: matches( + /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i, + ), + ipv4: matches( + /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/, + ), + regex, + // `m` as in ajv-formats, quirk included: a string passes if any one line does. + byte: matches(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/m), +} + +// The library reads formats from one shared table, with no per-validator +// option. Overwriting its entries would change validation for any other user of +// the library in the same bundle, so the dialect's formats are registered under +// their own names (`underlay:date`, …) on first compile, and our copy of each +// schema points at those (`namespaceFormat`). Formats the dialect doesn't know +// are dropped from the copy: they are ignored, and a name like `hasOwnProperty` +// never reaches the table's prototype. +const FORMAT_PREFIX = 'underlay:' +const DIALECT_FORMATS: Record boolean> = Object.assign( + Object.create(null) as Record boolean>, + Object.fromEntries(Object.entries(formats).filter(([k]) => !k.startsWith(FORMAT_PREFIX))), + AJV_FORMATS, +) +let formatsRegistered = false +function registerFormats(): void { + if (formatsRegistered) return + const table = formats as Record boolean> + for (const [name, check] of Object.entries(DIALECT_FORMATS)) table[FORMAT_PREFIX + name] = check + formatsRegistered = true +} + +/** In our copy of a schema node: point `format` at the dialect's definition, or drop it. */ +function namespaceFormat(node: Record): void { + if (typeof node.format !== 'string') return + if (Object.hasOwn(DIALECT_FORMATS, node.format)) node.format = FORMAT_PREFIX + node.format + else delete node.format +} + +// --- Messages ----------------------------------------------------------------- +// +// AJV's wording, which v1 clients have seen: `${instancePath || '/'} ${message}`. +// The library reports wrapper errors ("A subschema had errors.") above each +// leaf; AJV reports only the leaves, so wrappers are dropped. + +function messages( + errors: OutputUnit[], + root: Schema | boolean, + lookup: Lookup, + instance: unknown, +): string[] { + const out: string[] = [] + for (let i = 0; i < errors.length; i++) { + const e = errors[i]! + const message = describe(e, root, lookup, instance) + if (message !== null) out.push(`${instancePath(e.instanceLocation)} ${message}`) + // `additionalProperties: false` is reported by AJV once, on the object. The + // library follows its wrapper with a "False boolean schema" leaf for the + // property, whose keywordLocation is (wrongly) the instance location, so it + // can only be recognised by position. + const additional = e.keyword === 'additionalProperties' || e.keyword === 'additionalItems' + if (additional && message !== null && errors[i + 1]?.keyword === 'false') i++ + // A property name's own errors follow its wrapper, located at the property; + // AJV locates them at the object. + if (e.keyword === 'propertyNames') { + const name = /^Property name "(.*)" does/s.exec(e.error)?.[1] ?? '' + const at = `${e.instanceLocation}/${encodeURI(escapeSegment(name))}` + while (errors[i + 1]?.instanceLocation === at) { + const leaf = errors[++i]! + const leafMessage = describe(leaf, root, lookup, instance) + if (leafMessage !== null) out.push(`${instancePath(e.instanceLocation)} ${leafMessage}`) + } + } + } + return out +} + +function describe( + e: OutputUnit, + root: Schema | boolean, + lookup: Lookup, + instance: unknown, +): string | null { + const value = () => schemaAt(e.keywordLocation, root, lookup) + const quoted = (re: RegExp) => re.exec(e.error)?.slice(1) ?? [] + switch (e.keyword) { + case '$ref': + case 'properties': + case 'patternProperties': + case 'items': + case 'allOf': + return null + case 'false': + return 'boolean schema is false' + case 'additionalProperties': + return value() === false ? 'must NOT have additional properties' : null + case 'additionalItems': { + if (value() !== false) return null + const items = schemaAt(e.keywordLocation.replace(/additionalItems$/, 'items'), root, lookup) + return `must NOT have more than ${Array.isArray(items) ? items.length : 0} items` + } + case 'dependencies': { + const [key] = quoted(/^Instance has "(.*)" but does not have ".*"\.$/s) + if (key === undefined) return null // the schema form: its leaves carry the errors + const deps = (value() as Record | undefined)?.[key] + const list = Array.isArray(deps) ? deps : [] + return `must have ${list.length === 1 ? 'property' : 'properties'} ${list.join(', ')} when property ${key} is present` + } + case 'type': { + const t = value() + return `must be ${Array.isArray(t) ? t.join(',') : String(t)}` + } + case 'required': + return `must have required property '${quoted(/property "(.*)"\.$/s)[0]}'` + case 'const': + return 'must be equal to constant' + case 'enum': + return 'must be equal to one of the allowed values' + case 'not': + return 'must NOT be valid' + case 'anyOf': + return 'must match a schema in anyOf' + case 'oneOf': + return 'must match exactly one schema in oneOf' + case 'if': + return e.error.includes('"then"') ? 'must match "then" schema' : 'must match "else" schema' + case 'propertyNames': + return 'property name must be valid' + case 'contains': + return 'must contain at least 1 valid item(s)' + case 'uniqueItems': { + // AJV names the last duplicate pair; the library, the first. + const items = valueAt(e.instanceLocation, instance) + if (Array.isArray(items)) { + for (let i = items.length - 1; i > 0; i--) { + for (let j = i - 1; j >= 0; j--) { + if (deepCompareStrict(items[i], items[j])) { + return `must NOT have duplicate items (items ## ${j} and ${i} are identical)` + } + } + } + } + return 'must NOT have duplicate items' + } + case 'minimum': + return `must be >= ${String(value())}` + case 'maximum': + return `must be <= ${String(value())}` + case 'exclusiveMinimum': + return `must be > ${String(value())}` + case 'exclusiveMaximum': + return `must be < ${String(value())}` + case 'multipleOf': + return `must be multiple of ${String(value())}` + case 'minLength': + return `must NOT have fewer than ${String(value())} characters` + case 'maxLength': + return `must NOT have more than ${String(value())} characters` + case 'minItems': + return `must NOT have fewer than ${String(value())} items` + case 'maxItems': + return `must NOT have more than ${String(value())} items` + case 'minProperties': + return `must NOT have fewer than ${String(value())} properties` + case 'maxProperties': + return `must NOT have more than ${String(value())} properties` + case 'pattern': + return `must match pattern "${String(value())}"` + case 'format': + return `must match format "${String(value()).slice(FORMAT_PREFIX.length)}"` + default: + return e.error + } +} + +// The library writes locations as `#` plus JSON Pointer segments passed through +// encodeURI; AJV's instancePath is the plain JSON Pointer. +function instancePath(location: string): string { + let path = location.slice(1) + try { + path = decodeURI(path) + } catch { + // keep it encoded + } + return path || '/' +} + +const unescapeSegment = (s: string) => { + try { + s = decodeURI(s) + } catch { + // keep it encoded + } + return s.replace(/~1/g, '/').replace(/~0/g, '~') +} + +/** The instance value at an instance location. */ +function valueAt(location: string, root: unknown): unknown { + let node = root + for (const raw of location.split('/').slice(1)) { + if (node === null || typeof node !== 'object') return undefined + node = (node as Record)[unescapeSegment(raw)] + } + return node +} + +/** The schema value at a keyword location, following `$ref` segments through the lookup. */ +function schemaAt(location: string, root: Schema | boolean, lookup: Lookup): unknown { + let node: unknown = root + let inMap = false + for (const raw of location.split('/').slice(1)) { + if (node === null || typeof node !== 'object') return undefined + const seg = unescapeSegment(raw) + const obj = node as Schema + if (!inMap && seg === '$ref' && typeof obj.$ref === 'string') { + node = lookup[obj.__absolute_ref__ ?? obj.$ref] + continue + } + node = (node as Record)[seg] + inMap = !inMap && MAP_KEYWORDS.has(seg) + } + return node +} + +// --- Bounds and v2 rules ----------------------------------------------------- + +/** + * Check every schema in a push: see `checkSchema`. Returns the first error + * message, or null if all are acceptable. + */ +export function checkSchemaBounds(schemas: Record): string | null { + for (const [slug, body] of Object.entries(schemas)) { + const error = checkSchema(slug, body) + if (error) return error + } + return null +} + +/** + * Every acceptance check of protocol v2 sections 5 and 5.1 on one type's + * schema: `checkSchema`, then what compiling checks (an object at the root, a + * draft-07 `$schema`, the meta-schema, `u`-flag patterns, `$ref`s that + * resolve). The compiled validator is cached for the records that follow. + * Returns an error message, or null if the schema is acceptable; never throws. + */ +export function checkSchemaFull(slug: string, body: unknown): string | null { + try { + const error = checkSchema(slug, body) + if (error) return error + compileSchema(body) + return null + } catch (err) { + if (err instanceof SchemaError) return `Schema "${slug}": ${err.message}` + // Only a schema nested deep enough to overflow the stack gets here. + return `Schema "${slug}" could not be checked: ${(err as Error).message}` + } +} + +/** + * Bound a caller-supplied schema before it is compiled and run server-side, and + * apply the v2 schema rules (section 5; `checkSchemaFull` adds section 5.1's): + * + * - the type slug passes `checkTypeSlug`; + * - the canonical (JCS) schema is at most MAX_SCHEMA_BYTES; + * - no `pattern` or `patternProperties` key is longer than 256 characters; + * - a root `private`, if present, is a boolean; + * - no property is marked `"private": true`. v1 stripped such fields from + * public views; v2 has no field-level privacy, and accepting the marker + * would publish a field its author meant to hide. + * + * Returns an error message, or null if the schema is acceptable. + */ +export function checkSchema(slug: string, body: unknown): string | null { + const slugError = checkTypeSlug(slug) + if (slugError) return `Invalid type slug ${JSON.stringify(slug)}: ${slugError}` + let canonical: string + try { + canonical = jcs(body) + } catch (err) { + return `Schema "${slug}" is not JSON: ${(err as Error).message}` + } + if (utf8ByteLength(canonical) > MAX_SCHEMA_BYTES) { + return `Schema "${slug}" exceeds maximum size of ${MAX_SCHEMA_BYTES} bytes` + } + const longPattern = findLongPattern(body) + if (longPattern) { + return `Schema "${slug}" has a ${longPattern} longer than ${MAX_PATTERN_LENGTH} characters` + } + if (isObject(body) && body.private !== undefined && typeof body.private !== 'boolean') { + return `Schema "${slug}": "private" must be a boolean` + } + const privateField = findPrivateField(body, '') + if (privateField !== null) { + return ( + `Schema "${slug}" marks property ${privateField} as private: field-level privacy ` + + 'is not supported; make the whole type private instead' + ) + } + return null +} + +/** `node` is a schema, or an array of schemas; the walk is `findBadPattern`'s. */ +function findLongPattern(node: unknown): string | null { + if (Array.isArray(node)) { + for (const item of node) { + const found = findLongPattern(item) + if (found) return found + } + return null + } + if (!isObject(node)) return null + for (const [key, value] of Object.entries(node)) { + // A `pattern` member inside instance data is no regex. + if (DATA_KEYWORDS.has(key)) continue + if (key === 'pattern' && typeof value === 'string' && value.length > MAX_PATTERN_LENGTH) { + return '"pattern"' + } + // Keys of patternProperties are regexes too; v1 didn't bound them. + if (key === 'patternProperties' && isObject(value)) { + if (Object.keys(value).some((p) => p.length > MAX_PATTERN_LENGTH)) { + return '"patternProperties" pattern' + } + } + // A map's keys are names, not keywords: a property called "enum" is a schema. + const found = + MAP_KEYWORDS.has(key) && isObject(value) + ? findLongPattern(Object.values(value)) + : findLongPattern(value) + if (found) return found + } + return null +} + +/** + * The JSON Pointer of the first property schema with `"private": true`, at any + * depth: nested markers never did anything in v1 either, and are refused + * for the same reason. + */ +function findPrivateField(node: unknown, path: string): string | null { + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) { + const found = findPrivateField(node[i], `${path}/${i}`) + if (found !== null) return found + } + return null + } + if (!isObject(node)) return null + for (const [k, v] of Object.entries(node)) { + if (DATA_KEYWORDS.has(k)) continue + const at = `${path}/${escapeSegment(k)}` + // A map's keys are names, not keywords: a definition called "enum" is a schema. + if (MAP_KEYWORDS.has(k) && isObject(v)) { + for (const [name, sub] of Object.entries(v)) { + const subPath = `${at}/${escapeSegment(name)}` + if (k === 'properties' && isObject(sub) && sub.private === true) { + return JSON.stringify(subPath) + } + const found = findPrivateField(sub, subPath) + if (found !== null) return found + } + continue + } + const found = findPrivateField(v, at) + if (found !== null) return found + } + return null +} + +const escapeSegment = (s: string) => s.replace(/~/g, '~0').replace(/\//g, '~1') + +// --- Extra fields ------------------------------------------------------------- + +export interface ExtraFieldWarning { + recordId: string + type: string + fields: string[] +} + +/** Records with top-level fields their type's schema doesn't list in `properties`. */ +export function findExtraFields( + records: { recordId: string; type: string; data: unknown }[], + schemas: Record }>, +): ExtraFieldWarning[] { + const warnings: ExtraFieldWarning[] = [] + for (const rec of records) { + const props = schemas[rec.type]?.properties + if (!props || typeof rec.data !== 'object' || rec.data === null) continue + const extra = Object.keys(rec.data).filter((k) => !Object.hasOwn(props, k)) + if (extra.length > 0) warnings.push({ recordId: rec.recordId, type: rec.type, fields: extra }) + } + return warnings +} + +/** `data` without the top-level fields that `schemaProperties` doesn't list. */ +export function stripToSchema( + data: Record, + schemaProperties: Record, +): Record { + const result: Record = {} + for (const key of Object.keys(data)) { + if (Object.hasOwn(schemaProperties, key)) result[key] = data[key] + } + return result +} + +// --- Draft-07 meta-schema ----------------------------------------------------- +// +// http://json-schema.org/draft-07/schema, verbatim. AJV checks every schema +// against it before compiling; without the check a schema such as +// `{"type": "text"}` would compile and then reject every record. + +const META_SCHEMA: Schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'http://json-schema.org/draft-07/schema#', + title: 'Core schema meta-schema', + definitions: { + schemaArray: { type: 'array', minItems: 1, items: { $ref: '#' } }, + nonNegativeInteger: { type: 'integer', minimum: 0 }, + nonNegativeIntegerDefault0: { + allOf: [{ $ref: '#/definitions/nonNegativeInteger' }, { default: 0 }], + }, + simpleTypes: { enum: ['array', 'boolean', 'integer', 'null', 'number', 'object', 'string'] }, + stringArray: { type: 'array', items: { type: 'string' }, uniqueItems: true, default: [] }, + }, + type: ['object', 'boolean'], + properties: { + $id: { type: 'string', format: 'uri-reference' }, + $schema: { type: 'string', format: 'uri' }, + $ref: { type: 'string', format: 'uri-reference' }, + $comment: { type: 'string' }, + title: { type: 'string' }, + description: { type: 'string' }, + default: true, + readOnly: { type: 'boolean', default: false }, + examples: { type: 'array', items: true }, + multipleOf: { type: 'number', exclusiveMinimum: 0 }, + maximum: { type: 'number' }, + exclusiveMaximum: { type: 'number' }, + minimum: { type: 'number' }, + exclusiveMinimum: { type: 'number' }, + maxLength: { $ref: '#/definitions/nonNegativeInteger' }, + minLength: { $ref: '#/definitions/nonNegativeIntegerDefault0' }, + pattern: { type: 'string', format: 'regex' }, + additionalItems: { $ref: '#' }, + items: { anyOf: [{ $ref: '#' }, { $ref: '#/definitions/schemaArray' }], default: true }, + maxItems: { $ref: '#/definitions/nonNegativeInteger' }, + minItems: { $ref: '#/definitions/nonNegativeIntegerDefault0' }, + uniqueItems: { type: 'boolean', default: false }, + contains: { $ref: '#' }, + maxProperties: { $ref: '#/definitions/nonNegativeInteger' }, + minProperties: { $ref: '#/definitions/nonNegativeIntegerDefault0' }, + required: { $ref: '#/definitions/stringArray' }, + additionalProperties: { $ref: '#' }, + definitions: { type: 'object', additionalProperties: { $ref: '#' }, default: {} }, + properties: { type: 'object', additionalProperties: { $ref: '#' }, default: {} }, + patternProperties: { + type: 'object', + additionalProperties: { $ref: '#' }, + propertyNames: { format: 'regex' }, + default: {}, + }, + dependencies: { + type: 'object', + additionalProperties: { anyOf: [{ $ref: '#' }, { $ref: '#/definitions/stringArray' }] }, + }, + propertyNames: { $ref: '#' }, + const: true, + enum: { type: 'array', items: true, minItems: 1, uniqueItems: true }, + type: { + anyOf: [ + { $ref: '#/definitions/simpleTypes' }, + { + type: 'array', + items: { $ref: '#/definitions/simpleTypes' }, + minItems: 1, + uniqueItems: true, + }, + ], + }, + format: { type: 'string' }, + contentMediaType: { type: 'string' }, + contentEncoding: { type: 'string' }, + if: { $ref: '#' }, + then: { $ref: '#' }, + else: { $ref: '#' }, + allOf: { $ref: '#/definitions/schemaArray' }, + anyOf: { $ref: '#/definitions/schemaArray' }, + oneOf: { $ref: '#/definitions/schemaArray' }, + not: { $ref: '#' }, + }, + default: true, +} + +// The meta-schema's formats (uri, uri-reference, regex) are the dialect's too. +toDialect(META_SCHEMA) +const META_LOOKUP: Lookup = dereference(META_SCHEMA, Object.create(null) as Lookup, BASE_URI) diff --git a/packages/protocol/test/browser.test.ts b/packages/protocol/test/browser.test.ts new file mode 100644 index 0000000..c0144e7 --- /dev/null +++ b/packages/protocol/test/browser.test.ts @@ -0,0 +1,83 @@ +/** + * The package loads and works in a browser: bundled for the browser, then run + * in a VM context that has only web globals (no `process`, no `Buffer`, no + * `require`), so hashing takes the portable path. + */ +import { createContext, runInContext } from 'node:vm' + +import { describe, expect, it } from 'vitest' + +import { browserBundle } from '../scripts/browser-bundle.js' +import { hashRecord, sha256Hex } from '../src/index.js' + +const WEB_GLOBALS = [ + 'TextEncoder', + 'TextDecoder', + 'crypto', + 'CompressionStream', + 'DecompressionStream', + 'Blob', + 'Response', + 'Request', + 'Headers', + 'ReadableStream', + 'WritableStream', + 'TransformStream', + 'URL', + 'URLSearchParams', + 'atob', + 'btoa', + 'structuredClone', + 'queueMicrotask', + 'setTimeout', + 'clearTimeout', +] as const + +describe('in a browser', () => { + it('bundles with no Node imports and runs on web globals alone', async () => { + const esm = await browserBundle('esm') + expect(esm.gzip).toBeLessThan(200 * 1024) + const { code } = await browserBundle('iife') + + const sandbox: Record = {} + for (const name of WEB_GLOBALS) sandbox[name] = (globalThis as Record)[name] + const context = createContext(sandbox) + runInContext(code, context) + await runInContext( + `(async () => { + const p = underlay + const store = p.memoryStore() + const repo = p.openRepo(store) + const sink = new p.RepoSink(repo, { bodyOf: p.bodyOfRecord }) + const entries = [] + for (let i = 0; i < 3000; i++) { + const id = 'r' + String(i).padStart(4, '0') + const { hash, canonical } = p.hashRecord(id, 'T', { i }) + entries.push({ key: id, hash, size: canonical.length, body: canonical }) + } + const root = p.buildTree(p.recordTree, sink, entries) + await sink.flush() + const got = await p.getEntry(new p.RepoSource(p.recordTree, repo), root.hash, 'r1234') + globalThis.result = { + native: p.nativeSha256, + hasProcess: typeof process !== 'undefined', + abc: p.sha256Hex('abc'), + record: p.hashRecord('a', 'T', { x: 1 }).hash, + count: root.count, + found: got && got.hash, + want: entries[1234].hash, + version: p.PROTOCOL_VERSION, + } + })()`, + context, + ) + const r = sandbox.result as Record + expect(r.hasProcess).toBe(false) + expect(r.native).toBe(false) + expect(r.abc).toBe(sha256Hex('abc')) + expect(r.record).toBe(hashRecord('a', 'T', { x: 1 }).hash) + expect(r.count).toBe(3000) + expect(r.found).toBe(r.want) + expect(r.version).toBe(2) + }) +}) diff --git a/packages/protocol/test/input-rules.test.ts b/packages/protocol/test/input-rules.test.ts new file mode 100644 index 0000000..dfbb2ca --- /dev/null +++ b/packages/protocol/test/input-rules.test.ts @@ -0,0 +1,180 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' + +import { + compareUtf8, + InputRuleError, + parseRecordLine, + parseStrict, + scanJson, +} from '../src/index.js' + +const code = (f: () => unknown): string | null => { + try { + f() + return null + } catch (err) { + return err instanceof InputRuleError ? err.code : `other: ${(err as Error).message}` + } +} + +describe('scanJson', () => { + it('accepts ordinary JSON', () => { + expect( + code(() => scanJson('{"a":[1,2.5,-3,"x",true,false,null,{"b":{}}],"c":"\\"q\\""}')), + ).toBe(null) + }) + + it('rejects duplicate keys, compared after unescaping', () => { + expect(code(() => scanJson('{"a":1,"a":2}'))).toBe('duplicate_key') + expect(code(() => scanJson('{"a":1,"\\u0061":2}'))).toBe('duplicate_key') + expect(code(() => scanJson('{"x":{"a":1},"y":{"a":1}}'))).toBe(null) + expect(code(() => scanJson('[{"a":1},{"a":2}]'))).toBe(null) + expect(code(() => scanJson('{"a":{"a":{"a":1}}}'))).toBe(null) + // A key inside a nested value doesn't count toward the outer object. + expect(code(() => scanJson('{"a":{"b":1},"b":2}'))).toBe(null) + }) + + it('rejects integer literals outside ±(2^53−1) and keeps floats', () => { + expect(code(() => scanJson('[9007199254740991,-9007199254740991]'))).toBe(null) + expect(code(() => scanJson('[9007199254740992]'))).toBe('unsafe_integer') + expect(code(() => scanJson('{"n":-9007199254740992}'))).toBe('unsafe_integer') + expect(code(() => scanJson('[123456789012345678901234567890]'))).toBe('unsafe_integer') + expect(code(() => scanJson('[1e20,6.02e23,1.5,-0.0,1E+400]'))).toBe(null) + expect(code(() => scanJson('[9007199254740993.0]'))).toBe(null) + }) + + it('rejects lone surrogates, raw or escaped', () => { + expect(code(() => scanJson('["\\ud83d\\ude00", "😀"]'))).toBe(null) + expect(code(() => scanJson('["\\ud83d"]'))).toBe('lone_surrogate') + expect(code(() => scanJson('["\\ude00"]'))).toBe('lone_surrogate') + expect(code(() => scanJson('["\\ud83dx"]'))).toBe('lone_surrogate') + expect(code(() => scanJson('["\ud83d"]'))).toBe('lone_surrogate') + expect(code(() => scanJson('{"\\ud83d":1}'))).toBe('lone_surrogate') + // A raw high surrogate followed by an escaped low one decodes to a valid pair. + expect(code(() => scanJson('["\ud83d\\ude00"]'))).toBe(null) + }) + + it('limits nesting depth', () => { + expect(code(() => scanJson('[[[]]]', 3))).toBe(null) + expect(code(() => scanJson('[[[[]]]]', 3))).toBe('too_deep') + expect(code(() => scanJson('{"a":{"b":{"c":{}}}}', 3))).toBe('too_deep') + expect(code(() => scanJson('['.repeat(100_000)))).toBe('too_deep') + }) + + it('leaves syntax to JSON.parse', () => { + expect(code(() => parseStrict('{"a":'))).toBe('syntax') + expect(code(() => parseStrict('{"a":1}'))).toBe(null) + }) + + it('reports a key that does not unescape as syntax, at any depth', () => { + for (const text of [ + String.raw`{"id":"a","type":"A","data":{"\x":1}}`, + String.raw`{"\x":1,"id":"a","type":"A","data":1}`, + String.raw`{"id":"a","type":"A","data":[{"k":[{"a\q":1}]}]}`, + // A raw control character, which JSON.parse refuses in any string. + `{"id":"a","type":"A","data":{"\\n\n":1}}`, + ]) { + expect(code(() => parseStrict(text))).toBe('syntax') + expect(code(() => parseRecordLine(text))).toBe('syntax') + expect(code(() => scanJson(text))).toBe(null) + } + }) + + it('requires four hex digits in a \\u escape', () => { + expect(code(() => scanJson(String.raw`["\u00e9\u00E9"]`))).toBe(null) + for (const bad of ['12zz', ' 123', '+123', '-123', '12']) + expect(code(() => scanJson(`["\\u${bad}"]`))).toBe('syntax') + }) + + it('reports the first rule the text breaks, then syntax', () => { + // A duplicate key before a syntax error. + expect(code(() => parseStrict('{"a":1,"a":2,"b":}'))).toBe('duplicate_key') + // A bad escape other than \u is not a scan rule: the duplicate after it is. + expect(code(() => parseStrict(String.raw`{"\x":1,"a":1,"a":2}`))).toBe('duplicate_key') + expect(code(() => parseStrict(String.raw`{"a":"\x","a":1}`))).toBe('duplicate_key') + // A bad \u escape is, and comes first. + expect(code(() => parseStrict(String.raw`{"a":"\u12zz","a":1}`))).toBe('syntax') + }) + + it('never throws anything but InputRuleError, on any input', () => { + fc.assert( + fc.property(fc.string({ unit: 'binary' }), (s) => { + const c = code(() => parseStrict(s)) + return c === null || !c.startsWith('other') + }), + { numRuns: 2000 }, + ) + // Text made of JSON's own characters reaches keys with escapes. + const unit = fc.constantFrom(...'{}[]":,\\uxa0e1 ') + fc.assert( + fc.property(fc.string({ unit, maxLength: 40 }), (s) => { + const c = code(() => parseStrict(s)) + return c === null || !c.startsWith('other') + }), + { numRuns: 5000 }, + ) + }) + + it('accepts every JSON value without unsafe integers, duplicates or lone surrogates', () => { + const value = fc.jsonValue({ maxDepth: 6 }).filter((v) => { + const text = JSON.stringify(v) + return !/\d{16,}/.test(text) && (text ?? '').isWellFormed() + }) + fc.assert( + fc.property(value, (v) => code(() => scanJson(JSON.stringify(v))) === null), + { numRuns: 1000 }, + ) + }) +}) + +describe('parseRecordLine', () => { + it('parses and canonicalizes a record', () => { + const r = parseRecordLine( + '{"type":"Author","id":"r1","data":{"year":1815,"name":"Ada"},"private":true}', + ) + expect(r).toMatchObject({ id: 'r1', type: 'Author', private: true }) + expect(r.canonical).toBe('{"id":"r1","type":"Author","data":{"name":"Ada","year":1815}}') + }) + + it('rejects bad envelopes', () => { + expect(code(() => parseRecordLine('[1]'))).toBe('bad_envelope') + expect(code(() => parseRecordLine('{"id":"r","type":"t"}'))).toBe('bad_envelope') + expect(code(() => parseRecordLine('{"id":"","type":"t","data":1}'))).toBe('bad_id') + expect(code(() => parseRecordLine('{"id":1,"type":"t","data":1}'))).toBe('bad_id') + expect(code(() => parseRecordLine(`{"id":"${'x'.repeat(1025)}","type":"t","data":1}`))).toBe( + 'bad_id', + ) + expect(code(() => parseRecordLine('{"id":"r","type":"a/b","data":1}'))).toBe('bad_type') + expect(code(() => parseRecordLine('{"id":"r","type":".x","data":1}'))).toBe('bad_type') + expect(code(() => parseRecordLine('{"id":"r","type":"t","data":1,"private":"yes"}'))).toBe( + 'bad_envelope', + ) + }) + + it('counts the envelope as one level of nesting', () => { + const deep = (n: number) => '['.repeat(n) + ']'.repeat(n) + expect(code(() => parseRecordLine(`{"id":"r","type":"t","data":${deep(64)}}`))).toBe(null) + expect(code(() => parseRecordLine(`{"id":"r","type":"t","data":${deep(65)}}`))).toBe('too_deep') + }) +}) + +describe('compareUtf8', () => { + it('orders like UTF-8 bytes', () => { + const str = fc.string({ unit: 'grapheme', maxLength: 6 }) + fc.assert( + fc.property(str, str, (a, b) => { + const want = Math.sign(Buffer.compare(Buffer.from(a), Buffer.from(b))) + return Math.sign(compareUtf8(a, b)) === want + }), + { numRuns: 5000 }, + ) + }) + + it('puts astral characters after U+E000–U+FFFF, unlike JS sort', () => { + const bmp = String.fromCharCode(0xffff) + const astral = String.fromCodePoint(0x1f600) + expect(compareUtf8(bmp, astral)).toBeLessThan(0) + expect(bmp < astral).toBe(false) + }) +}) diff --git a/packages/protocol/test/jcs.test.ts b/packages/protocol/test/jcs.test.ts new file mode 100644 index 0000000..232b2dd --- /dev/null +++ b/packages/protocol/test/jcs.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' + +import { hashRecord, hashSchema, jcs } from '../src/index.js' + +describe('jcs', () => { + it('matches RFC 8785 §3.2.2 (primitives, numbers, escaping)', () => { + const input = JSON.parse( + '{"numbers":[333333333.33333329,1E30,4.50,2e-3,0.000000000000000000000000001],' + + '"string":"\\u20ac$\\u000F\\u000aA\'\\u0042\\u0022\\u005c\\\\\\"\\/",' + + '"literals":[null,true,false]}', + ) + expect(jcs(input)).toBe( + '{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],' + + '"string":"€$\\u000f\\nA\'B\\"\\\\\\\\\\"/"}', + ) + }) + + it('matches RFC 8785 §3.2.3 (key sorting by UTF-16 code units)', () => { + const input = JSON.parse( + '{"\\u20ac":"Euro Sign","\\r":"Carriage Return","\\ufb33":"Hebrew Letter Dalet With Dagesh",' + + '"1":"One","\\ud83d\\ude00":"Emoji: Grinning Face","\\u0080":"Control",' + + '"\\u00f6":"Latin Small Letter O With Diaeresis"}', + ) + // Built from code points: the formatter turns \u escapes into literal + // characters, and U+FB33 would not survive an NFC-normalizing editor. + const k = (cp: number) => JSON.stringify(String.fromCodePoint(cp)) + expect(jcs(input)).toBe( + `{"\\r":"Carriage Return","1":"One",${k(0x80)}:"Control",${k(0xf6)}:"Latin Small Letter O With Diaeresis",` + + `${k(0x20ac)}:"Euro Sign",${k(0x1f600)}:"Emoji: Grinning Face",${k(0xfb33)}:"Hebrew Letter Dalet With Dagesh"}`, + ) + }) + + it('matches RFC 8785 Appendix B number serialization', () => { + const f = (hex: string) => + new DataView(new Uint8Array(Buffer.from(hex, 'hex')).buffer).getFloat64(0) + const cases: [string, string][] = [ + ['0000000000000000', '0'], + ['8000000000000000', '0'], + ['0000000000000001', '5e-324'], + ['8000000000000001', '-5e-324'], + ['7fefffffffffffff', '1.7976931348623157e+308'], + ['4340000000000000', '9007199254740992'], + ['c340000000000000', '-9007199254740992'], + ['4430000000000000', '295147905179352830000'], + ['44b52d02c7e14af5', '9.999999999999997e+22'], + ['44b52d02c7e14af6', '1e+23'], + ['3eb0c6f7a0b5ed8d', '0.000001'], + ['3eb0c6f7a0b5ed8c', '9.999999999999997e-7'], + ] + for (const [hex, want] of cases) expect(jcs(f(hex))).toBe(want) + }) + + it('sorts integer-like keys as strings, unlike JS enumeration', () => { + expect(jcs({ a: 1, 10: 2, 9: 3 })).toBe('{"10":2,"9":3,"a":1}') + expect(jcs({ x: { 2: 'b', 10: 'a' } })).toBe('{"x":{"10":"a","2":"b"}}') + }) + + it('refuses values JSON cannot hold', () => { + expect(() => jcs(Number.NaN)).toThrow() + expect(() => jcs(Infinity)).toThrow() + expect(() => jcs(undefined)).toThrow() + expect(() => jcs(10n)).toThrow() + }) +}) + +describe('record and schema hashes', () => { + it('hashes a record as its fixed envelope', () => { + const { hash, canonical } = hashRecord('r1', 'Author', { name: 'Ada', year: 1815 }) + expect(canonical).toBe('{"id":"r1","type":"Author","data":{"name":"Ada","year":1815}}') + expect(hash).toBe('adefbd10aa438f0c6ed1627817f391ac6cc0441737ee09b4ebcc30fbd8386c63') + }) + + it('hashes a schema as its canonical JSON', () => { + const schema = { type: 'object', properties: { name: { type: 'string' } } } + expect(hashSchema(schema)).toBe( + '2b7196d853bac7cea83330be9c2073848dedc10746eaf403bb5f73687531baf2', + ) + }) + + it('sorts integer-like keys as strings, not numbers', () => { + // JSON.stringify of a JS object would put "9" first. + expect(hashRecord('r', 't', { 9: 'x', 10: 'y' }).canonical).toBe( + '{"id":"r","type":"t","data":{"10":"y","9":"x"}}', + ) + }) +}) diff --git a/packages/protocol/test/repo/gzip.test.ts b/packages/protocol/test/repo/gzip.test.ts new file mode 100644 index 0000000..c7bda57 --- /dev/null +++ b/packages/protocol/test/repo/gzip.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { gunzipMembers, gunzipText, gzip } from '../../src/repo/gzip.js' + +describe('gunzip', () => { + // A large leaf body is several gzip members in one object. workerd's + // DecompressionStream reads only the first (checked under wrangler dev, + // 2026-10-04), so gunzip goes through native zlib where the runtime has it. + it('reads every member of a multi-member body', async () => { + const parts = await Promise.all(['a\n', 'b\n', 'c\n'].map((s) => gzip(s))) + const all = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0)) + let at = 0 + for (const p of parts) { + all.set(p, at) + at += p.byteLength + } + expect(await gunzipText(all)).toBe('a\nb\nc\n') + }) +}) + +describe('gunzip where the decoder reads one member (browsers)', () => { + // Stands in for a DecompressionStream that reads exactly one member and fails + // on anything else: inflate the deflate data, then check the trailer's CRC and + // size, so a slice cut anywhere but a member's end throws. + const zlib = process.getBuiltinModule('node:zlib') + const oneMember = async (b: Uint8Array) => { + if (b.length < 18 || b[0] !== 0x1f || b[1] !== 0x8b) throw new Error('not gzip') + const view = Buffer.from(b.buffer, b.byteOffset, b.byteLength) + const out = zlib.inflateRawSync(view.subarray(10, view.length - 8)) + if (view.readUInt32LE(view.length - 4) !== out.length >>> 0) throw new Error('trailing bytes') + if (view.readUInt32LE(view.length - 8) !== zlib.crc32(out)) throw new Error('bad crc') + return new Uint8Array(out) + } + + it('splits the members and reads them all', async () => { + const texts = ['first line\n', 'x'.repeat(5000) + '\n', '{"id":"\u001f\u008b"}\n', 'last\n'] + const parts = await Promise.all(texts.map((s) => gzip(s))) + const all = Buffer.concat(parts) + await expect(oneMember(all)).rejects.toThrow() + const out = await gunzipMembers(all, oneMember) + expect(new TextDecoder().decode(out)).toBe(texts.join('')) + // One member decodes in one go. + expect(new TextDecoder().decode(await gunzipMembers(parts[0]!, oneMember))).toBe(texts[0]) + // Garbage still fails. + await expect(gunzipMembers(new Uint8Array([1, 2, 3]), oneMember)).rejects.toThrow() + }) +}) diff --git a/packages/protocol/test/repo/log.test.ts b/packages/protocol/test/repo/log.test.ts new file mode 100644 index 0000000..8b3f85d --- /dev/null +++ b/packages/protocol/test/repo/log.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' + +import { + appendLog, + ed25519Signer, + entryHash, + generateSigningKey, + keyIdOf, + readHead, + signEntry, + verifyEntry, + verifyLog, +} from '../../src/repo/log.js' +import { IntegrityError, keys, Repo } from '../../src/repo/repo.js' +import { memoryStore } from '../../src/stores/memory.js' + +const repoOver = (blobs = memoryStore()) => ({ + blobs, + repo: new Repo(blobs, { scope: 't', trusted: true }), +}) + +const entry = (seq: number, prev: string | null) => ({ + collectionId: 'c1', + seq, + semver: `v1.${seq - 1}.0`, + versionHash: `ulv2:${String(seq).padStart(64, '0')}`, + baseSemver: seq === 1 ? null : `v1.${seq - 2}.0`, + message: `push ${seq}`, + appId: null, + actorId: null, + createdAt: new Date(Date.UTC(2026, 9, 3, 12, seq)).toISOString(), + prev, +}) + +describe('version log', () => { + it('signs and verifies entries with Ed25519', async () => { + const signer = await ed25519Signer(await generateSigningKey()) + const e = await signEntry(signer, entry(1, null)) + expect(await verifyEntry(e, [signer.publicKey])).toBe(true) + expect(await verifyEntry({ ...e, message: 'tampered' }, [signer.publicKey])).toBe(false) + const other = await ed25519Signer(await generateSigningKey()) + expect(await verifyEntry(e, [other.publicKey])).toBe(false) + }) + + it('trusts a key only under its own id', async () => { + const trusted = await ed25519Signer(await generateSigningKey()) + const forger = await ed25519Signer(await generateSigningKey()) + expect(keyIdOf(trusted.publicKey.publicKey)).toBe(trusted.keyId) + // The forger signs under the trusted key's id and lists its own key under that id. + const e = await signEntry({ ...forger, keyId: trusted.keyId }, entry(1, null)) + expect(await verifyEntry(e, [{ ...forger.publicKey, id: trusted.keyId }])).toBe(false) + expect(await verifyEntry(e, [trusted.publicKey])).toBe(false) + }) + + it("doesn't take one collection's log for another's", async () => { + const { blobs, repo } = repoOver() + const signer = await ed25519Signer(await generateSigningKey()) + let prev: string | null = null + for (let seq = 1; seq <= 2; seq++) { + const e = await signEntry(signer, { ...entry(seq, prev), collectionId: 'c1' }) + await appendLog(repo, 'c1', e) + prev = entryHash(e) + } + await expect(verifyLog(repo, 'c1', [signer.publicKey])).resolves.toBeTruthy() + // The same objects filed under another collection: every signature is good. + for (const [k, v] of [...blobs.objects]) { + if (k.startsWith('collections/c1/')) + blobs.objects.set(k.replace('collections/c1/', 'collections/c2/'), v) + } + await expect(verifyLog(repo, 'c2', [signer.publicKey])).rejects.toThrow(/names collection c1/) + }) + + it('derives the same key from the same seed', async () => { + const seed = await generateSigningKey() + expect((await ed25519Signer(seed)).publicKey).toEqual((await ed25519Signer(seed)).publicKey) + }) + + it('appends a hash-chained log and verifies it end to end', async () => { + const { blobs, repo } = repoOver() + const signer = await ed25519Signer(await generateSigningKey()) + let prev: string | null = null + for (let seq = 1; seq <= 3; seq++) { + const e = await signEntry(signer, entry(seq, prev)) + await appendLog(repo, 'c1', e) + prev = entryHash(e) + } + expect((await readHead(repo, 'c1'))!.seq).toBe(3) + const { entries } = await verifyLog(repo, 'c1', [signer.publicKey]) + expect(entries.map((e) => e.seq)).toEqual([1, 2, 3]) + + // A dropped entry breaks the chain. + const two = blobs.objects.get(keys.logEntry('c1', 2))! + blobs.objects.delete(keys.logEntry('c1', 2)) + await expect(verifyLog(repo, 'c1', [signer.publicKey])).rejects.toThrow(IntegrityError) + blobs.objects.set(keys.logEntry('c1', 2), two) + await expect(verifyLog(repo, 'c1', [signer.publicKey])).resolves.toBeTruthy() + }) +}) diff --git a/packages/protocol/test/repo/objects.test.ts b/packages/protocol/test/repo/objects.test.ts new file mode 100644 index 0000000..7d396c3 --- /dev/null +++ b/packages/protocol/test/repo/objects.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest' + +import { + compareUtf8, + diffTrees, + fileTree, + hashRecord, + iterate, + mergeTree, + type RecordEntry, + recordTree, + TreeBuilder, + utf8ByteLength, + verifyTree, + emptySet, + jcs, + makeRoot, + PROTOCOL_VERSION, + sha256Hex, + SUPPORTED_PROTOCOL_VERSIONS, + UnsupportedProtocolError, +} from '../../src/format.js' +import { gzip } from '../../src/repo/gzip.js' +import { Lru } from '../../src/repo/lru.js' +import { + bodyOfRecord, + dropRecordBody, + IntegrityError, + keys, + OUT_OF_LINE_BYTES, + recordPayloadBytes, + Repo, + RepoSink, + RepoSource, +} from '../../src/repo/repo.js' +import { PrefixedStore } from '../../src/repo/types.js' +import { memoryStore } from '../../src/stores/memory.js' + +const freshRepo = (opts: { trusted?: boolean } = {}) => { + const blobs = memoryStore() + // A private LRU per test, so reads really go to the store. + const repo = new Repo(blobs, { + scope: 'test', + trusted: opts.trusted ?? true, + lru: new Lru(8 << 20, () => 1024), + }) + return { blobs, repo } +} + +const record = (id: string, v = 0): RecordEntry => { + const { hash, canonical } = hashRecord(id, 'T', { id, v, pad: 'x'.repeat(200) }) + return { key: id, hash, size: utf8ByteLength(canonical), body: canonical } +} + +const ids = (n: number) => + Array.from({ length: n }, (_, i) => `rec-${String(i).padStart(6, '0')}`).sort(compareUtf8) + +async function collect(it: AsyncIterable): Promise { + const out: T[] = [] + for await (const x of it) out.push(x) + return out +} + +async function buildRecords(repo: Repo, n: number) { + const sink = new RepoSink(repo, { bodyOf: bodyOfRecord }) + const b = new TreeBuilder(recordTree, sink) + for (const id of ids(n)) b.addEntry(record(id)) + const root = b.finish().root! + await sink.flush() + return { root, sink } +} + +describe('record trees in a repository', () => { + it('round-trips nodes and bodies in the documented layout, and verifies', async () => { + const { blobs, repo } = freshRepo() + const { root, sink } = await buildRecords(repo, 5000) + expect(root.count).toBe(5000) + const stored = [...blobs.objects.keys()] + expect( + stored.every((k) => /^nodes\/[0-9a-f]{64}$|^bodies\/[0-9a-f]{64}\.ndjson\.gz$/.test(k)), + ).toBe(true) + // The sink lists exactly what it wrote: a commit's sync work for mirrors. + expect(new Set(sink.written)).toEqual(new Set(stored)) + + const source = new RepoSource(recordTree, repo) + expect((await verifyTree(source, root.hash)).ok).toBe(true) + const rows = await collect(iterate(source, root.hash, { payloads: true, offset: 4990 })) + expect(rows.map((r) => r.key)).toEqual(ids(5000).slice(4990)) + for (const r of rows) expect(JSON.parse(r.body!).id).toBe(r.key) + }) + + it('merges against stored trees, rewriting only changed leaves', async () => { + const { blobs, repo } = freshRepo() + const { root: base } = await buildRecords(repo, 20_000) + const putsBefore = blobs.puts + const all = ids(20_000) + const source = new RepoSource(recordTree, repo) + const changes = [all[10]!, all[15_000]!].map((id) => ({ key: id, entry: record(id, 1) })) + const sink = new RepoSink(repo, { bodyOf: bodyOfRecord }) + const merged = await mergeTree(source, sink, base.hash, changes) + await sink.flush() + expect(merged.stats.updated).toBe(2) + expect(blobs.puts - putsBefore).toBeLessThanOrEqual(2 * 2 + 2 * (base.level + 1)) + const diff = await collect(diffTrees(source, base.hash, merged.root!.hash)) + expect(diff.map((d) => d.key)).toEqual([all[10], all[15_000]]) + const changed = await collect( + iterate(source, merged.root!.hash, { payloads: true, offset: 10 }), + ) + expect(JSON.parse(changed[0]!.body!).data.v).toBe(1) + }) + + it('writes a large leaf body as several gzip members in one object', async () => { + const { blobs, repo } = freshRepo() + const sink = new RepoSink(repo, { bodyOf: bodyOfRecord }) + const b = new TreeBuilder(recordTree, sink, { + payloadBytes: recordPayloadBytes, + dropPayload: dropRecordBody, + spillBytes: 20_000, // tiny, to force several members + }) + for (const id of ids(3000)) b.addEntry(record(id)) + const root = b.finish().root! + await sink.flush() + const body = [...blobs.objects.entries()].find(([k]) => k.startsWith('bodies/'))![1].bytes + let members = 0 + for (let i = 0; i + 2 < body.length; i++) + if (body[i] === 0x1f && body[i + 1] === 0x8b && body[i + 2] === 8) members++ + expect(members).toBeGreaterThan(1) + const rows = await collect( + iterate(new RepoSource(recordTree, repo), root.hash, { payloads: true }), + ) + expect(rows.length).toBe(3000) + expect(rows.every((r) => JSON.parse(r.body!).id === r.key)).toBe(true) + }) + + it('resolves out-of-line records', async () => { + const { repo } = freshRepo() + const big = hashRecord('big', 'T', { blob: 'y'.repeat(OUT_OF_LINE_BYTES + 10) }) + const pointer = await repo.putOutOfLine(big.hash, big.canonical) + const sink = new RepoSink(repo, { bodyOf: bodyOfRecord }) + const b = new TreeBuilder(recordTree, sink) + b.addEntry(record('a')) + b.addEntry({ key: 'big', hash: big.hash, size: utf8ByteLength(big.canonical), body: pointer }) + const root = b.finish().root! + await sink.flush() + const rows = await collect( + iterate(new RepoSource(recordTree, repo), root.hash, { payloads: true }), + ) + expect(rows[1]!.body).toBe(big.canonical) + }) + + it('stores file trees without bodies', async () => { + const { blobs, repo } = freshRepo() + const sink = new RepoSink(repo) + const b = new TreeBuilder(fileTree, sink) + b.addEntry({ key: 'a'.repeat(64), size: 10 }) + b.addEntry({ key: 'b'.repeat(64), size: 20 }) + const root = b.finish().root! + await sink.flush() + expect([...blobs.objects.keys()]).toEqual([keys.node(root.hash)]) + const entries = await collect(iterate(new RepoSource(fileTree, repo), root.hash)) + expect(entries.map((e) => e.size)).toEqual([10, 20]) + }) + + it('verifies bodies from untrusted locations, and never caches a bad one', async () => { + const { blobs, repo } = freshRepo() + const { root } = await buildRecords(repo, 300) + // Tamper with one record in the stored body. + const bodyKey = [...blobs.objects.keys()].find((k) => k.startsWith('bodies/'))! + const lines = ( + await new Response( + new Blob([blobs.objects.get(bodyKey)!.bytes as Uint8Array]) + .stream() + .pipeThrough(new DecompressionStream('gzip')), + ).text() + ).split('\n') + lines[5] = lines[5]!.replace('"v":0', '"v":9') + blobs.objects.set(bodyKey, { bytes: await gzip(lines.join('\n')), contentType: null }) + + const untrusted = new Repo(blobs, { + scope: 'mirror', + trusted: false, + lru: new Lru(8 << 20, () => 1024), + }) + await expect( + collect(iterate(new RepoSource(recordTree, untrusted), root.hash, { payloads: true })), + ).rejects.toThrow(IntegrityError) + }) + + it("refuses an untrusted body line that isn't its entry's record", async () => { + for (const lie of [ + (e: RecordEntry) => ({ ...e, size: e.size + 1 }), + (e: RecordEntry) => { + // Hashes right, but holds another id's record. + const other = record('someone-else') + return { ...other, key: e.key } + }, + ]) { + const { blobs, repo } = freshRepo() + const sink = new RepoSink(repo, { bodyOf: bodyOfRecord }) + const b = new TreeBuilder(recordTree, sink) + ids(20).forEach((id, i) => b.addEntry(i === 3 ? lie(record(id)) : record(id))) + const root = b.finish().root! + await sink.flush() + const untrusted = new Repo(blobs, { trusted: false, lru: new Lru(8 << 20, () => 1024) }) + await expect( + collect(iterate(new RepoSource(recordTree, untrusted), root.hash, { payloads: true })), + ).rejects.toThrow(/isn't its entry's record/) + } + }) + + it('refuses roots of a protocol version it does not read', async () => { + for (const trusted of [true, false]) { + const { blobs, repo } = freshRepo({ trusted }) + const future = { underlay: 3, metadata: null, public: emptySet(), private: null } + const digest = sha256Hex(jcs(future)) + await blobs.put(`roots/${digest}.json`, jcs(future)) + await expect(repo.root(`ulv3:${digest}`)).rejects.toThrow(UnsupportedProtocolError) + await expect(repo.root(digest)).rejects.toThrow(/protocol version 3 is not supported/) + } + const { repo } = freshRepo() + const ok = makeRoot(null, emptySet(), null) + expect((await repo.root(await repo.putRoot(ok))).underlay).toBe(PROTOCOL_VERSION) + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(PROTOCOL_VERSION) + }) + + it('keeps each location under its prefix', async () => { + const shared = memoryStore() + const repo = new Repo(new PrefixedStore(shared, 'underlay/v2'), { + scope: 'p', + trusted: true, + }) + await repo.putSchema({ type: 'object' }) + expect([...shared.objects.keys()][0]).toMatch(/^underlay\/v2\/schemas\/[0-9a-f]{64}\.json$/) + }) +}) diff --git a/packages/protocol/test/repo/sync.test.ts b/packages/protocol/test/repo/sync.test.ts new file mode 100644 index 0000000..141e9b0 --- /dev/null +++ b/packages/protocol/test/repo/sync.test.ts @@ -0,0 +1,510 @@ +import { describe, expect, it } from 'vitest' + +import { + buildTree, + type Change, + compareUtf8, + emptySet, + type FileEntry, + fileTree, + fixedChunking, + hashRecord, + hashSchema, + IntegrityError, + iterate, + makeRoot, + memoryStore, + mergeTree, + newNodes, + newSalt, + openRepo, + OUT_OF_LINE_BYTES, + type PackObject, + packVersion, + type PrivateSetObject, + receiveVersion, + type RecordEntry, + recordTree, + type Repo, + RepoSink, + RepoSource, + type SetObject, + sha256Hex, + tarStream, + untar, + utf8ByteLength, + bodyOfRecord, + checkPrivateSetObject, + checkSetObject, + checkVersionRoot, + jcs, + privateCommitment, + versionHash, + type VersionRoot, +} from '../../src/index.js' + +const SCHEMA = { type: 'object', properties: { v: { type: 'integer' } } } +const SCHEMA2 = { type: 'object', properties: { v: { type: 'integer' }, w: {} } } + +async function collect(it: AsyncIterable): Promise { + const out: T[] = [] + for await (const x of it) out.push(x) + return out +} + +/** A record; every 997th is large enough to be stored out of line. */ +async function record(repo: Repo, type: string, id: string, v: number): Promise { + const big = id.endsWith('7') && Number(id.replace(/\D/g, '')) % 997 === 0 + const { hash, canonical } = hashRecord(id, type, { + v, + ...(big ? { pad: 'x'.repeat(OUT_OF_LINE_BYTES) } : {}), + }) + const size = utf8ByteLength(canonical) + const body = big ? await repo.putOutOfLine(hash, canonical) : canonical + return { key: id, hash, size, body } +} + +/** A version-making helper over a "sender" repository. */ +function sender() { + const repo = openRepo(memoryStore(), { trusted: true }) + const salt = newSalt() + return { + repo, + async tree(entries: RecordEntry[], base: string | null = null) { + const sink = new RepoSink(repo, { bodyOf: bodyOfRecord }) + const changes: Change[] = entries + .slice() + .sort((a, b) => compareUtf8(a.key, b.key)) + .map((e) => ({ key: e.key, entry: e })) + const r = await mergeTree(new RepoSource(recordTree, repo), sink, base, changes) + await sink.flush() + return r.root + }, + async files(hashes: string[]) { + const sink = new RepoSink(repo) + const root = buildTree( + fileTree, + sink, + hashes.sort().map((h) => ({ key: h, size: h.length })), + ) + await sink.flush() + return root ? { root: root.hash, count: root.count, bytes: root.bytes } : emptySet().files + }, + async version(pub: SetObject, priv: SetObject | null) { + await repo.putSchema(SCHEMA) + await repo.putSchema(SCHEMA2) + const privateSet: PrivateSetObject | null = priv ? { ...priv, salt } : null + if (privateSet) await repo.putPrivateSet(privateSet) + return repo.putRoot(makeRoot({ title: 't' }, pub, privateSet)) + }, + } +} + +const summary = (d: { hash: string; count: number; bytes: number } | null) => + d ? { root: d.hash, count: d.count, bytes: d.bytes } : { root: null, count: 0, bytes: 0 } + +const ids = (prefix: string, n: number) => + Array.from({ length: n }, (_, i) => `${prefix}${String(i).padStart(6, '0')}`) + +/** Every record (with body) of every tree of a version, by set and type. */ +async function contents(repo: Repo, version: string, withPrivate: boolean) { + const root = await repo.root(version) + const out: Record = {} + const sets: [string, SetObject][] = [['public', root.public]] + if (withPrivate && root.private) sets.push(['private', await repo.privateSet(root.private)]) + const src = new RepoSource(recordTree, repo) + for (const [name, set] of sets) { + for (const [slug, t] of Object.entries(set.types)) { + const es = await collect(iterate(src, t.root, { payloads: true })) + out[`${name}/${slug}`] = es.map((e) => `${e.key}:${sha256Hex(e.body!)}`) + } + } + return out +} + +async function twoVersions() { + const s = sender() + const a = await Promise.all(ids('a', 6000).map((id, i) => record(s.repo, 'A', id, i))) + const p = await Promise.all(ids('p', 500).map((id, i) => record(s.repo, 'P', id, i))) + const ta = await s.tree(a) + const tp = await s.tree(p) + const f1 = await s.files([sha256Hex('f1'), sha256Hex('f2')]) + const schema = hashSchema(SCHEMA) + const v1 = await s.version( + { types: { A: { schema, ...summary(ta) } }, files: f1 }, + { types: { P: { schema, ...summary(tp) } }, files: emptySet().files }, + ) + // v2: updates and inserts in A, a new type B, a file added, P updated. + const changed = await Promise.all( + [...ids('a', 6000).filter((_, i) => i % 400 === 0), ...ids('a9', 30)].map((id, i) => + record(s.repo, 'A', id, 10_000 + i), + ), + ) + const ta2 = await s.tree(changed, ta!.hash) + const tb = await s.tree( + await Promise.all(ids('b', 1500).map((id, i) => record(s.repo, 'B', id, i))), + ) + const tp2 = await s.tree([await record(s.repo, 'P', 'p000003', 99)], tp!.hash) + const f2 = await s.files([sha256Hex('f1'), sha256Hex('f2'), sha256Hex('f3')]) + const v2 = await s.version( + { + types: { + A: { schema, ...summary(ta2) }, + B: { schema: hashSchema(SCHEMA2), ...summary(tb) }, + }, + files: f2, + }, + { types: { P: { schema, ...summary(tp2) } }, files: emptySet().files }, + ) + return { s, v1, v2 } +} + +describe('tree sync', () => { + it('moves a whole version, then only what changed', async () => { + const { s, v1, v2 } = await twoVersions() + const r = openRepo(memoryStore()) + const full = await collect(packVersion(s.repo, v1)) + await receiveVersion(r, full, { target: v1 }) + expect(await contents(r, v1, false)).toEqual(await contents(s.repo, v1, false)) + + const delta = await collect(packVersion(s.repo, v2, { base: v1 })) + expect(delta.length).toBeLessThan(full.length) + // Only the new type's leaves come whole; A's changes touch a few leaves. + const result = await receiveVersion(r, delta, { target: v2, base: v1 }) + expect(result.root).toEqual(await s.repo.root(v2)) + expect(await contents(r, v2, false)).toEqual(await contents(s.repo, v2, false)) + // A second receive of the same pack is a no-op that still verifies. + await receiveVersion(r, delta, { target: v2, base: v1 }) + }) + + it("holds out-of-line records to the receiver's size policy when asked", async () => { + const { s, v1 } = await twoVersions() + const full = await collect(packVersion(s.repo, v1)) + // The sender stores only records over OUT_OF_LINE_BYTES out of line: accepted. + await receiveVersion(openRepo(memoryStore()), full, { + target: v1, + inlineUpTo: OUT_OF_LINE_BYTES, + }) + // A receiver that keeps every record of up to 1 MB inline refuses them. + await expect( + receiveVersion(openRepo(memoryStore()), full, { target: v1, inlineUpTo: 1024 * 1024 }), + ).rejects.toThrow(/out of line; this repository keeps records up to/) + }) + + it('leaves private sets out unless asked, and moves them when asked', async () => { + const { s, v1, v2 } = await twoVersions() + const pub = await collect(packVersion(s.repo, v1)) + expect(pub.some((o) => o.key.startsWith('private/'))).toBe(false) + // No node or body of the private tree travels in a public pack. + const root = await s.repo.root(v1) + const priv = await s.repo.privateSet(root.private!) + const src = new RepoSource(recordTree, s.repo) + const privKeys = new Set() + for await (const n of newNodes(src, null, priv.types.P!.root)) { + privKeys.add(`nodes/${n.hash}`) + if (n.level === 0) privKeys.add(`bodies/${n.hash}.ndjson.gz`) + } + expect(privKeys.size).toBeGreaterThan(1) + expect(pub.filter((o) => privKeys.has(o.key))).toEqual([]) + + const r = openRepo(memoryStore()) + await receiveVersion(r, await collect(packVersion(s.repo, v1, { sets: 'all' })), { + target: v1, + sets: 'all', + }) + const delta = await collect(packVersion(s.repo, v2, { base: v1, sets: 'all' })) + expect(delta.some((o) => o.key.startsWith('private/'))).toBe(true) + await receiveVersion(r, delta, { target: v2, base: v1, sets: 'all' }) + expect(await contents(r, v2, true)).toEqual(await contents(s.repo, v2, true)) + }) + + it('travels as a tar', async () => { + const { s, v1 } = await twoVersions() + const objects = await collect(packVersion(s.repo, v1)) + const tar = tarStream( + objects.map((o) => ({ + name: o.key, + size: o.bytes.byteLength, + body: async function* () { + yield o.bytes + }, + })), + ) + const r = openRepo(memoryStore()) + const back = (async function* (): AsyncGenerator { + for await (const f of untar(tar)) yield { key: f.name, bytes: f.bytes } + })() + await receiveVersion(r, back, { target: v1 }) + expect(await contents(r, v1, false)).toEqual(await contents(s.repo, v1, false)) + }) + + describe('refuses a bad pack, and never writes its root', () => { + const attempt = async (edit: (objects: PackObject[]) => PackObject[], pattern: RegExp) => { + const { s, v1, v2 } = await twoVersions() + const r = openRepo(memoryStore()) + await receiveVersion(r, await collect(packVersion(s.repo, v1)), { target: v1 }) + const objects = edit(await collect(packVersion(s.repo, v2, { base: v1 }))) + const err = await receiveVersion(r, objects, { target: v2, base: v1 }).catch((e: Error) => e) + expect(err).toBeInstanceOf(IntegrityError) + expect((err as Error).message).toMatch(pattern) + expect(await r.blobs.head(`roots/${v2.slice(5)}.json`)).toBe(null) + } + const flip = (b: Uint8Array) => { + const c = b.slice() + c[c.length - 9]! ^= 1 + return c + } + + it('a node whose bytes are wrong', () => + attempt( + (os) => os.map((o) => (o.key.startsWith('nodes/') ? { ...o, bytes: flip(o.bytes) } : o)), + /./, + )) + + it('a leaf without its body', () => + attempt((os) => os.filter((o) => !o.key.startsWith('bodies/')), /has no body/)) + + it("a body holding another leaf's records", async () => { + await attempt((os) => { + const bodies = os.filter((o) => o.key.startsWith('bodies/')) + const [x, y] = [bodies[0]!, bodies[1]!] + return os.map((o) => (o === x ? { ...o, bytes: y.bytes } : o)) + }, /line count|fails its hash/) + }) + + it('a node missing from the pack', () => + attempt((os) => { + const i = os.findIndex((o) => o.key.startsWith('nodes/')) + return os.filter((_, j) => j !== i) + }, /./)) + + it('the root of another version', async () => { + const { s, v1, v2 } = await twoVersions() + const r = openRepo(memoryStore()) + const objects = await collect(packVersion(s.repo, v1)) + const err = await receiveVersion(r, objects, { target: v2 }).catch((e: Error) => e) + expect((err as Error).message).toMatch(/another version's root/) + }) + + it('an object a pack may not carry', () => + attempt( + (os) => [{ key: 'collections/x/head.json', bytes: new Uint8Array([1]) }, ...os], + /can't hold/, + )) + }) + + describe("refuses leaf entries that don't match their records", () => { + // A hostile sender builds a canonical tree from entries that lie about their + // records: every node and body line still hashes to what points at it. + // Every object in the sender's store, in pack order, packed by hand: the + // library itself won't pack a node it can't decode. + const rawPack = (repo: Repo): PackObject[] => { + const order = ['schemas/', 'nodes/', 'records/', 'bodies/', 'private/', 'roots/'] + return [...(repo.blobs as ReturnType).objects.entries()] + .map(([key, o]) => ({ key, bytes: o.bytes })) + .sort( + (a, b) => + order.findIndex((p) => a.key.startsWith(p)) - + order.findIndex((p) => b.key.startsWith(p)), + ) + } + const hostile = async ( + lie: (good: RecordEntry) => RecordEntry, + pattern: RegExp, + byHand = false, + ) => { + const s = sender() + const entries = await Promise.all(ids('a', 50).map((id, i) => record(s.repo, 'A', id, i))) + entries[7] = lie(entries[7]!) + const tree = await s.tree(entries) + const v = await s.version( + { types: { A: { schema: hashSchema(SCHEMA), ...summary(tree) } }, files: emptySet().files }, + null, + ) + const r = openRepo(memoryStore()) + const pack = byHand ? rawPack(s.repo) : await collect(packVersion(s.repo, v)) + const err = await receiveVersion(r, pack, { target: v }).catch((e: Error) => e) + expect(err).toBeInstanceOf(IntegrityError) + expect((err as Error).message).toMatch(pattern) + expect(await r.blobs.head(`roots/${v.slice(5)}.json`)).toBe(null) + } + const line = (id: string, type: string, text: string) => { + const size = utf8ByteLength(text) + return { key: id, hash: sha256Hex(text), size, body: text } + } + + it('a size that is not the record’s', () => + hostile((e) => ({ ...e, size: e.size + 5 }), /size/)) + + it('an id that is not the record’s', () => + hostile((e) => { + const { canonical } = hashRecord('someone-else', 'A', { v: 7 }) + return line(e.key, 'A', canonical) + }, /id/)) + + it('a record of another type', () => + hostile((e) => line(e.key, 'B', hashRecord(e.key, 'B', { v: 7 }).canonical), /type/)) + + it('a record line that is not canonical', () => + hostile( + (e) => line(e.key, 'A', hashRecord(e.key, 'A', { v: 7 }).canonical.replace(':7', ': 7')), + /canonical/, + )) + + it('a key with a lone surrogate', () => + hostile( + (e) => { + const id = `${e.key}\uD800` + return line(id, 'A', hashRecord(id, 'A', { v: 7 }).canonical) + }, + /surrogate/, + true, + )) + + it('a key over the id length limit', () => + hostile( + (e) => { + const id = `${e.key}${'x'.repeat(1100)}` + return line(id, 'A', hashRecord(id, 'A', { v: 7 }).canonical) + }, + /id length/, + true, + )) + }) + + it('refuses a tree that hashes correctly but is not canonical', async () => { + // Built under a different chunking: every node is well formed and hashes to + // its key, but the protocol would never build this tree from these entries. + const s = sender() + const entries = await Promise.all(ids('a', 3000).map((id, i) => record(s.repo, 'A', id, i))) + const sink = new RepoSink(s.repo, { bodyOf: bodyOfRecord }) + const odd = buildTree(recordTree, sink, entries, { + chunking: fixedChunking({ leafBits: 6, stepBits: 2, leafMax: 512, interiorMax: 64 }), + })! + await sink.flush() + const v = await s.version( + { types: { A: { schema: hashSchema(SCHEMA), ...summary(odd) } }, files: emptySet().files }, + null, + ) + const r = openRepo(memoryStore()) + const err = await receiveVersion(r, await collect(packVersion(s.repo, v)), { target: v }).catch( + (e: Error) => e, + ) + expect((err as Error).message).toMatch(/not the canonical tree/) + }) + + describe('refuses a root or private set of the wrong shape (section 10)', () => { + const enc = new TextEncoder() + const H = (c: string) => c.repeat(64) + const good = { underlay: 2, metadata: null, public: emptySet(), private: null } + const entry = { schema: H('a'), root: null, count: 0, bytes: 0 } + const rootObject = (root: unknown): PackObject => ({ + key: `roots/${versionHash(root as VersionRoot).slice(5)}.json`, + bytes: enc.encode(jcs(root)), + }) + const receive = async ( + root: unknown, + extra: PackObject[] = [], + r = openRepo(memoryStore()), + ) => { + const target = versionHash(root as VersionRoot) + const sets = extra.length > 0 ? ('all' as const) : ('public' as const) + const err = await receiveVersion(r, [...extra, rootObject(root)], { target, sets }).then( + () => null, + (e: Error) => e, + ) + if (err) { + expect(err).toBeInstanceOf(IntegrityError) + expect(await r.blobs.head(`roots/${target.slice(5)}.json`)).toBe(null) + } + return err?.message ?? null + } + + it('accepts a well-formed root', async () => { + expect(await receive(good)).toBe(null) + expect(await receive({ ...good, metadata: { title: 't' } })).toBe(null) + }) + + it('refuses members other than underlay, metadata, public and private', async () => { + expect(await receive({ ...good, extra: 1 })).toMatch(/root: unexpected fields/) + const { private: _p, ...noPrivate } = good + expect(await receive(noPrivate)).toMatch(/root: unexpected fields/) + }) + + it('refuses metadata that is not an object or null', async () => { + for (const metadata of [[], ['a'], 'text', 1, true]) + expect(await receive({ ...good, metadata })).toMatch(/metadata must be an object or null/) + }) + + it('refuses a public set of the wrong shape', async () => { + const pub = (p: unknown) => receive({ ...good, public: p }) + expect(await pub({ ...emptySet(), extra: 1 })).toMatch(/public set: unexpected fields/) + expect(await pub({ types: [], files: emptySet().files })).toMatch(/public set: bad types/) + expect(await pub({ types: {}, files: { root: null, count: 0, bytes: 5 } })).toMatch( + /root and bytes disagree/, + ) + expect(await pub({ types: {}, files: { ...emptySet().files, x: 1 } })).toMatch( + /files: unexpected fields/, + ) + expect( + await pub({ types: { T: { ...entry, schema: 'x' } }, files: emptySet().files }), + ).toMatch(/bad schema hash/) + for (const slug of [ + 'a/b', + '.hidden', + 'a\\b', + 'tab\t', + String.fromCharCode(0x7f), + 'x'.repeat(129), + ]) + expect(await pub({ types: { [slug]: entry }, files: emptySet().files })).toMatch( + /Type (must|exceeds)/, + ) + }) + + it('refuses a private member that is not a commitment', async () => { + for (const priv of ['xyz', H('A'), 1, {}]) + expect(await receive({ ...good, private: priv })).toMatch(/bad private commitment/) + }) + + it('refuses an empty or malformed private set, received or already held', async () => { + const set = (s: Omit, salt = newSalt()): PrivateSetObject => ({ + ...s, + salt, + }) + const withPrivate = (p: PrivateSetObject) => { + const commitment = privateCommitment(p) + const root = { ...good, private: commitment } + const obj = { key: `private/${commitment}.json`, bytes: enc.encode(jcs(p)) } + return { root, obj } + } + // A root names a private set only when it is non-empty. + const empty = withPrivate(set(emptySet())) + expect(await receive(empty.root, [empty.obj])).toMatch(/private\/.*: set: empty/) + const badSalt = withPrivate(set(emptySet(), 'salt')) + expect(await receive(badSalt.root, [badSalt.obj])).toMatch(/set: bad salt/) + // Already held here, so not in the pack: checked all the same. + const r = openRepo(memoryStore()) + await r.putPrivateSet(set(emptySet(), H('b'))) + const held = withPrivate(set(emptySet(), H('b'))) + const err = await receiveVersion(r, [rootObject(held.root)], { + target: versionHash(held.root as VersionRoot), + sets: 'all', + }).catch((e: Error) => e) + expect(err).toBeInstanceOf(IntegrityError) + expect((err as Error).message).toMatch(/^Private set .*: set: empty/) + }) + + it('agrees with the roots and sets the library writes', async () => { + const { s, v1, v2 } = await twoVersions() + for (const v of [v1, v2]) { + const root = await s.repo.root(v) + expect(checkVersionRoot(root)).toBe(null) + expect(checkPrivateSetObject(await s.repo.privateSet(root.private!))).toBe(null) + } + expect(checkVersionRoot(makeRoot(null, emptySet(), null))).toBe(null) + expect(checkVersionRoot({ ...good, underlay: 3 })).toMatch(/bad protocol version/) + expect(checkSetObject(emptySet())).toBe(null) + }) + }) +}) diff --git a/packages/protocol/test/sha256.test.ts b/packages/protocol/test/sha256.test.ts new file mode 100644 index 0000000..4bf7b7b --- /dev/null +++ b/packages/protocol/test/sha256.test.ts @@ -0,0 +1,29 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' + +import { + nativeSha256, + portableSha256, + portableSha256Hex, + sha256, + sha256Hex, +} from '../src/sha256.js' + +describe('sha256', () => { + it('is native under Node', () => { + expect(nativeSha256).toBe(true) + }) + + it('the browser fallback matches the native hash (property)', () => { + fc.assert( + fc.property(fc.oneof(fc.string({ unit: 'binary' }), fc.uint8Array()), (input) => { + expect(portableSha256Hex(input)).toBe(sha256Hex(input)) + expect(portableSha256(input)).toEqual(new Uint8Array(sha256(input))) + }), + { numRuns: 500 }, + ) + expect(portableSha256Hex('abc')).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ) + }) +}) diff --git a/packages/protocol/test/stores/contract.test.ts b/packages/protocol/test/stores/contract.test.ts new file mode 100644 index 0000000..7cdd839 --- /dev/null +++ b/packages/protocol/test/stores/contract.test.ts @@ -0,0 +1,329 @@ +/** + * The store contract: what every store does, run against all of them, plain + * and under a prefix. memory, file, s3 (an in-process fake of the S3 API), and + * r2 (Miniflare's R2 binding). + */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { convertV4MiniflareOptions, Miniflare } from 'miniflare' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { + copyObject, + fileStore, + listAll, + memoryStore, + MIN_PART_BYTES, + PrefixedStore, + putFromParts, + type R2BucketLike, + readParts, + r2Store, + s3CopyLimits, + s3Store, + serveSignedBlob, + type Store, +} from '../../src/index.js' +import { type FakeS3, startFakeS3 } from './fake-s3.js' + +const enc = new TextEncoder() +const cleanups: (() => Promise)[] = [] +afterAll(async () => { + for (const c of cleanups.splice(0)) await c() +}) + +async function tempDir() { + const dir = await mkdtemp(join(tmpdir(), 'ul-store-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function fakeS3Store() { + const fake: FakeS3 = await startFakeS3() + cleanups.push(() => fake.close()) + return s3Store({ endpoint: fake.url, bucket: 'test', accessKeyId: 'k', secretAccessKey: 's' }) +} + +async function miniflareStore() { + const mf = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + script: 'export default { fetch: () => new Response() }', + compatibilityDate: '2026-09-01', + r2Buckets: ['B'], + }), + ) + cleanups.push(() => mf.dispose()) + return r2Store((await mf.getR2Bucket('B')) as unknown as R2BucketLike) +} + +const makers: [string, () => Promise][] = [ + ['memory', async () => memoryStore()], + ['file', async () => fileStore(await tempDir())], + ['s3', fakeS3Store], + ['r2', miniflareStore], + ['prefixed memory', async () => new PrefixedStore(memoryStore(), 'some/prefix')], + ['prefixed s3', async () => new PrefixedStore(await fakeS3Store(), 'some/prefix')], + ['prefixed r2', async () => new PrefixedStore(await miniflareStore(), 'p')], +] + +for (const [name, make] of makers) { + describe(`${name} store`, () => { + let store: Store + beforeAll(async () => { + store = await make() + }) + + it('puts, gets, heads, reads ranges and deletes', async () => { + await store.put('a/b c/obj', 'hello world', { contentType: 'text/plain' }) + const obj = await store.get('a/b c/obj') + expect(obj?.size).toBe(11) + expect(obj?.contentType).toBe('text/plain') + expect(await obj!.text()).toBe('hello world') + const head = await store.head('a/b c/obj') + expect(head?.size).toBe(11) + expect(head?.contentType).toBe('text/plain') + const mid = await store.get('a/b c/obj', { offset: 6, length: 3 }) + expect(mid?.size).toBe(3) + expect(await mid!.text()).toBe('wor') + const tail = await store.get('a/b c/obj', { offset: 6 }) + expect(tail?.size).toBe(5) + expect(await tail!.text()).toBe('world') + const clipped = await store.get('a/b c/obj', { offset: 9, length: 10 }) + expect(clipped?.size).toBe(2) + expect(await clipped!.text()).toBe('ld') + await store.delete('a/b c/obj') + expect(await store.get('a/b c/obj')).toBe(null) + expect(await store.head('a/b c/obj')).toBe(null) + }) + + it('answers null for absent keys and deletes them without complaint', async () => { + expect(await store.get('absent')).toBe(null) + expect(await store.get('absent', { offset: 0, length: 1 })).toBe(null) + expect(await store.head('absent')).toBe(null) + await store.delete('absent') + }) + + it('round-trips bytes exactly', async () => { + const bytes = Uint8Array.from({ length: 70_000 }, (_, i) => (i * 7919) & 0xff) + await store.put('bin', bytes) + expect(await (await store.get('bin'))!.bytes()).toEqual(bytes) + expect(await (await store.get('bin', { offset: 65_536, length: 10 }))!.bytes()).toEqual( + bytes.subarray(65_536, 65_546), + ) + }) + + it('treats ifAbsent on an existing key as success without overwriting', async () => { + await store.put('immutable', 'first') + await store.put('immutable', 'second', { ifAbsent: true }) + expect(await (await store.get('immutable'))!.text()).toBe('first') + await store.put('fresh', 'only', { ifAbsent: true }) + expect(await (await store.get('fresh'))!.text()).toBe('only') + }) + + it('streams bodies', async () => { + await store.put('stream', enc.encode('x'.repeat(100_000))) + const obj = await store.get('stream') + expect((await new Response(obj!.body).text()).length).toBe(100_000) + }) + + it('writes from parts, and a failed check leaves nothing behind', async () => { + async function* parts(n: number, size: number) { + for (let i = 0; i < n; i++) yield new Uint8Array(size).fill(65 + i) + } + await putFromParts(store, 'parts/ok', parts(3, 1000), { contentType: 'text/plain' }) + const obj = await store.get('parts/ok') + expect(obj!.size).toBe(3000) + expect((await obj!.text()).slice(995, 1005)).toBe('AAAAABBBBB') + await expect( + putFromParts(store, 'parts/bad', parts(2, 10), { + check: () => { + throw new Error('hash mismatch') + }, + }), + ).rejects.toThrow('hash mismatch') + expect(await store.head('parts/bad')).toBeNull() + await putFromParts(store, 'parts/ok', parts(1, 5), { ifAbsent: true }) + expect((await store.head('parts/ok'))!.size).toBe(3000) + await putFromParts(store, 'parts/empty', parts(0, 0)) + expect((await store.head('parts/empty'))!.size).toBe(0) + // Big enough for a real multipart upload: two full parts and a short one. + const big = MIN_PART_BYTES + await putFromParts( + store, + 'parts/big', + (async function* () { + yield* parts(2, big) + yield new Uint8Array(7).fill(90) + })(), + ) + expect((await store.head('parts/big'))!.size).toBe(2 * big + 7) + const tail = await store.get('parts/big', { offset: big - 2, length: 4 }) + expect(await tail!.text()).toBe('AABB') + await expect( + putFromParts(store, 'parts/big-bad', parts(3, big), { + check: () => { + throw new Error('size mismatch') + }, + }), + ).rejects.toThrow('size mismatch') + expect(await store.head('parts/big-bad')).toBeNull() + // readParts gives back the same bytes in ranges. + const back: number[] = [] + for await (const p of readParts(store, 'parts/ok', 3000, 1024)) back.push(p.byteLength) + expect(back).toEqual([1024, 1024, 952]) + }) + + it('copies, natively or by reading and writing', async () => { + await store.put('copy/src', 'copied bytes', { contentType: 'text/plain' }) + await copyObject(store, 'copy/src', 'copy/dest/x') + expect(await (await store.get('copy/dest/x'))!.text()).toBe('copied bytes') + }) + + it('lists by prefix in byte order', async () => { + for (const k of ['list/b', 'list/a', 'list/c/d', 'listing', 'other/3']) await store.put(k, k) + expect((await store.list('list/')).keys).toEqual(['list/a', 'list/b', 'list/c/d']) + expect((await store.list('list')).keys).toEqual(['list/a', 'list/b', 'list/c/d', 'listing']) + }) + + it('pages through more than 1,000 keys with opaque cursors', async () => { + const keys = Array.from({ length: 1005 }, (_, i) => `many/${String(i).padStart(4, '0')}`) + for (let i = 0; i < keys.length; i += 50) { + await Promise.all(keys.slice(i, i + 50).map((k) => store.put(k, ''))) + } + const first = await store.list('many/') + expect(first.keys.length).toBeLessThan(1005) + expect(first.cursor).toBeDefined() + const all: string[] = [] + for await (const k of listAll(store, 'many/')) all.push(k) + expect(all).toEqual(keys) + }, 30_000) + + it('presigns, when it can', async () => { + const p = store.presigner + if (!p) return + expect(await p.presignGet('x', { expiresIn: 60 })).toBeTruthy() + expect(await p.presignPut('x', { expiresIn: 60 })).toBeTruthy() + const id = await p.createMultipart('big') + expect(await p.presignPart('big', id, 1, 60)).toBeTruthy() + await p.abortMultipart('big', id) + }) + }) +} + +describe('capabilities', () => { + it('the r2 store neither presigns nor copies; s3 does both', async () => { + const r2 = await miniflareStore() + expect(r2.presigner).toBeUndefined() + expect(r2.copy).toBeUndefined() + const s3 = await fakeS3Store() + expect(s3.presigner).toBeDefined() + expect(s3.copy).toBeDefined() + expect(fileStore('x').presigner).toBeUndefined() + expect(new PrefixedStore(s3, 'p').presigner).toBeDefined() + }) +}) + +describe('cache-control on put', () => { + const cc = 'public, max-age=31536000, immutable' + it('s3 sends it with the object, and it is served back', async () => { + const fake: FakeS3 = await startFakeS3() + cleanups.push(() => fake.close()) + const store = s3Store({ + endpoint: fake.url, + bucket: 'test', + accessKeyId: 'k', + secretAccessKey: 's', + }) + await store.put('a.png', 'x', { contentType: 'image/png', cacheControl: cc }) + expect(fake.objects.get('a.png')?.cacheControl).toBe(cc) + const res = await fetch(`${fake.url}/test/a.png`, { + headers: { authorization: 'AWS4-HMAC-SHA256 test' }, + }) + expect(res.headers.get('cache-control')).toBe(cc) + }) + + it('r2 stores it in the http metadata', async () => { + const mf = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + script: 'export default { fetch: () => new Response() }', + compatibilityDate: '2026-09-01', + r2Buckets: ['B'], + }), + ) + cleanups.push(() => mf.dispose()) + const bucket = await mf.getR2Bucket('B') + await r2Store(bucket as unknown as R2BucketLike).put('a.png', 'x', { + contentType: 'image/png', + cacheControl: cc, + }) + const head = await bucket.head('a.png') + expect(head?.httpMetadata?.cacheControl).toBe(cc) + expect(head?.httpMetadata?.contentType).toBe('image/png') + }) + + it('memory keeps it', async () => { + const store = memoryStore() + await store.put('a.png', 'x', { cacheControl: cc }) + expect(store.objects.get('a.png')?.cacheControl).toBe(cc) + }) +}) + +describe('file store presigned URLs', () => { + it('serve GET and PUT with a valid signature only', async () => { + const store = fileStore(await tempDir(), { publicUrl: 'http://localhost:4100', secret: 'k' }) + const put = await store.presigner.presignPut('uploads/u1', { expiresIn: 60 }) + const res = await serveSignedBlob(store, new Request(put, { method: 'PUT', body: 'bytes!' })) + expect(res.status).toBe(200) + const get = await store.presigner.presignGet('uploads/u1', { + expiresIn: 60, + disposition: 'attachment', + }) + const got = await serveSignedBlob(store, new Request(get)) + expect(await got.text()).toBe('bytes!') + expect(got.headers.get('content-disposition')).toBe('attachment') + const tampered = get.replace('uploads%2Fu1', 'uploads%2Fu2').replace('uploads/u1', 'uploads/u2') + expect((await serveSignedBlob(store, new Request(tampered))).status).toBe(403) + const wrongMethod = await serveSignedBlob(store, new Request(get, { method: 'PUT', body: 'x' })) + expect(wrongMethod.status).toBe(403) + }) +}) + +describe('s3 multipart copy', () => { + it('copies an object over the CopyObject limit in UploadPartCopy ranges', async () => { + const store = await fakeS3Store() + const bytes = new Uint8Array(1000).map((_, i) => i % 251) + await store.put('big/src', bytes) + const saved = { ...s3CopyLimits } + Object.assign(s3CopyLimits, { copyObjectMax: 100, partBytes: 300 }) + try { + await copyObject(store, 'big/src', 'big/dest', 1000) + } finally { + Object.assign(s3CopyLimits, saved) + } + expect(await (await store.get('big/dest'))!.bytes()).toEqual(bytes) + }) +}) + +describe('s3 multipart', () => { + it('assembles parts uploaded to presigned part URLs', async () => { + const store = await fakeS3Store() + const p = store.presigner + const id = await p.createMultipart('files/big', 'application/octet-stream') + const parts = [] + for (const [n, text] of [ + [1, 'part one,'], + [2, 'part two'], + ] as const) { + const url = await p.presignPart('files/big', id, n, 60) + expect(url).toContain('X-Amz-Signature') + const r = await fetch(url, { method: 'PUT', body: text }) + parts.push({ partNumber: n, etag: r.headers.get('etag')! }) + } + await p.completeMultipart('files/big', id, parts) + expect(await (await store.get('files/big'))!.text()).toBe('part one,part two') + }) +}) diff --git a/packages/protocol/test/stores/fake-s3-server.ts b/packages/protocol/test/stores/fake-s3-server.ts new file mode 100644 index 0000000..995a6dc --- /dev/null +++ b/packages/protocol/test/stores/fake-s3-server.ts @@ -0,0 +1,8 @@ +/** + * Run the fake S3 server standalone, for `wrangler dev` smoke tests: + * npx tsx test/fake-s3-server.ts [port] [bucket] + */ +import { startFakeS3 } from './fake-s3.js' + +const s3 = await startFakeS3(process.argv[3] ?? 'underlay-staging', Number(process.argv[2] ?? 0)) +console.log(`fake S3 at ${s3.url}`) diff --git a/packages/protocol/test/stores/fake-s3.ts b/packages/protocol/test/stores/fake-s3.ts new file mode 100644 index 0000000..1ed3c0c --- /dev/null +++ b/packages/protocol/test/stores/fake-s3.ts @@ -0,0 +1,210 @@ +/** + * A tiny S3-compatible server for adapter tests: path-style GET (with Range), PUT + * (with If-None-Match), HEAD, DELETE, ListObjectsV2 and multipart uploads. It + * checks that requests are signed (header or query) but doesn't verify + * signatures; signing itself is aws4fetch's. + */ +import { createServer, type Server } from 'node:http' + +export interface FakeS3 { + url: string + objects: Map< + string, + { bytes: Buffer; contentType: string | undefined; cacheControl?: string | undefined } + > + requests: { method: string; url: string }[] + /** The bucket's lifecycle configuration XML (null: none); `'denied'` refuses reading it. */ + lifecycle: { xml: string | null | 'denied' } + close(): Promise +} + +export async function startFakeS3( + bucket = 'test', + listenPort = 0, + /** region: refuse requests signed for another region, as S3 does, and name it on a bucket HEAD. */ + opts: { publicRead?: boolean; region?: string } = {}, +): Promise { + const objects: FakeS3['objects'] = new Map() + const uploads = new Map }>() + const requests: { method: string; url: string }[] = [] + const lifecycle: FakeS3['lifecycle'] = { xml: null } + + const server: Server = createServer(async (req, res) => { + const chunks: Buffer[] = [] + for await (const c of req) chunks.push(c as Buffer) + const body = Buffer.concat(chunks) + const url = new URL(req.url!, 'http://x') + requests.push({ method: req.method!, url: req.url! }) + const signed = + !!req.headers.authorization?.startsWith('AWS4-HMAC-SHA256') || + url.searchParams.has('X-Amz-Signature') + if (opts.region) { + const scope = + /Credential=[^/]+\/\d+\/([^/]+)\//.exec(req.headers.authorization ?? '')?.[1] ?? + url.searchParams.get('X-Amz-Credential')?.split('/')[2] + const where = { 'x-amz-bucket-region': opts.region } + if (req.method === 'HEAD' && url.pathname.split('/').filter(Boolean).length === 1) + return void res.writeHead(scope === opts.region ? 200 : 403, where).end() + if (signed && scope !== opts.region) + return void res + .writeHead(400, where) + .end('AuthorizationHeaderMalformed') + } + if (!signed && !(opts.publicRead && req.method === 'GET')) { + res.writeHead(403).end('AccessDenied') + return + } + const [, b, ...rest] = url.pathname.split('/') + if (b !== bucket) { + res.writeHead(404).end('NoSuchBucket') + return + } + const key = rest.map(decodeURIComponent).join('/') + const q = url.searchParams + + if (req.method === 'GET' && !key && q.has('lifecycle')) { + if (lifecycle.xml === 'denied') + return void res.writeHead(403).end('AccessDenied') + if (lifecycle.xml === null) + return void res + .writeHead(404) + .end('NoSuchLifecycleConfiguration') + res.writeHead(200, { 'content-type': 'application/xml' }).end(lifecycle.xml) + return + } + if (req.method === 'GET' && !key && q.get('list-type') === '2') { + // Pages of max-keys (1,000 by default), with an opaque continuation token. + const prefix = q.get('prefix') ?? '' + const max = Number(q.get('max-keys') ?? 1000) + const token = q.get('continuation-token') + const after = token ? Buffer.from(token, 'base64url').toString() : null + const all = [...objects.keys()] + .filter((k) => k.startsWith(prefix) && (after === null || k > after)) + .sort() + const keys = all.slice(0, max) + const next = + all.length > max + ? `true${Buffer.from(keys[keys.length - 1]!).toString('base64url')}` + : 'false' + res.writeHead(200, { 'content-type': 'application/xml' }) + res.end( + `${keys.map((k) => `${k}`).join('')}${next}`, + ) + return + } + if (req.method === 'POST' && q.has('uploads')) { + const id = crypto.randomUUID() + uploads.set(id, { key, parts: new Map() }) + res + .writeHead(200) + .end( + `${id}`, + ) + return + } + if (req.method === 'PUT' && q.has('uploadId')) { + const up = uploads.get(q.get('uploadId')!) + if (!up) return void res.writeHead(404).end('NoSuchUpload') + const n = q.get('partNumber') + // UploadPartCopy: the part is a range of another object. + const copySource = req.headers['x-amz-copy-source'] + if (typeof copySource === 'string') { + const src = objects.get(decodeURIComponent(copySource.replace(/^\/[^/]+\//, ''))) + if (!src) return void res.writeHead(404).end('NoSuchKey') + const r = /^bytes=(\d+)-(\d+)$/.exec(String(req.headers['x-amz-copy-source-range'] ?? '')) + up.parts.set( + Number(n), + r ? src.bytes.subarray(Number(r[1]), Number(r[2]) + 1) : Buffer.from(src.bytes), + ) + res.writeHead(200).end(`"c${n}"`) + return + } + up.parts.set(Number(n), body) + res.writeHead(200, { etag: `"p${n}"` }).end() + return + } + if (req.method === 'POST' && q.has('uploadId')) { + const up = uploads.get(q.get('uploadId')!) + if (!up) return void res.writeHead(404).end('NoSuchUpload') + const nums = [...body.toString().matchAll(/(\d+)<\/PartNumber>/g)].map((m) => + Number(m[1]), + ) + objects.set(up.key, { + bytes: Buffer.concat(nums.map((n) => up.parts.get(n)!)), + contentType: undefined, + }) + uploads.delete(q.get('uploadId')!) + res.writeHead(200).end('') + return + } + if (req.method === 'DELETE' && q.has('uploadId')) { + uploads.delete(q.get('uploadId')!) + res.writeHead(204).end() + return + } + const obj = objects.get(key) + switch (req.method) { + case 'PUT': { + const copySource = req.headers['x-amz-copy-source'] + if (typeof copySource === 'string') { + const srcKey = decodeURIComponent(copySource.replace(/^\/[^/]+\//, '')) + const src = objects.get(srcKey) + if (!src) return void res.writeHead(404).end('NoSuchKey') + objects.set(key, { bytes: Buffer.from(src.bytes), contentType: src.contentType }) + res.writeHead(200).end('"x"') + return + } + if (req.headers['if-none-match'] === '*' && obj) { + res.writeHead(412).end('PreconditionFailed') + return + } + objects.set(key, { + bytes: body, + contentType: req.headers['content-type'], + cacheControl: req.headers['cache-control'], + }) + res.writeHead(200, { etag: '"x"' }).end() + return + } + case 'HEAD': + case 'GET': { + if (!obj) + return void res + .writeHead(404) + .end(req.method === 'GET' ? 'NoSuchKey' : undefined) + let bytes = obj.bytes + let status = 200 + const range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.range ?? '') + if (range) { + const start = Number(range[1]) + const end = range[2] ? Number(range[2]) : bytes.length - 1 + bytes = bytes.subarray(start, end + 1) + status = 206 + } + const headers: Record = { + 'content-length': String(bytes.length), + etag: '"x"', + } + if (obj.contentType) headers['content-type'] = obj.contentType + if (obj.cacheControl) headers['cache-control'] = obj.cacheControl + res.writeHead(status, headers) + res.end(req.method === 'GET' ? bytes : undefined) + return + } + case 'DELETE': + objects.delete(key) + res.writeHead(204).end() + return + } + res.writeHead(400).end() + }) + await new Promise((r) => server.listen(listenPort, '127.0.0.1', r)) + const port = (server.address() as { port: number }).port + return { + url: `http://127.0.0.1:${port}`, + objects, + requests, + lifecycle, + close: () => new Promise((r) => server.close(() => r())), + } +} diff --git a/packages/protocol/test/tree.test.ts b/packages/protocol/test/tree.test.ts new file mode 100644 index 0000000..8905cb8 --- /dev/null +++ b/packages/protocol/test/tree.test.ts @@ -0,0 +1,715 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' + +import { + assembleTree, + boundaryBytes, + buildTree, + type Change, + compareUtf8, + diffTrees, + entryAt, + getEntry, + inRange, + iterate, + type KeyRange, + MapSource, + MemorySink, + mergeTree, + newNodes, + protocolChunking, + rankOf, + type NodeDesc, + type NodeSource, + type RecordEntry, + recordTree, + type Segment, + sha256Hex, + trailingZeros, + type TreeSink, + verifyTree, +} from '../src/index.js' +import { fixedChunking as fixed } from '../src/tree/chunking.js' + +// Tiny nodes so a few hundred entries make deep trees with forced splits: +// mean leaf 4 entries (forced at 8), mean fanout 2 (forced at 4). +const RUNS = Number(process.env.PROPERTY_RUNS ?? 1) +const tiny = fixed({ leafBits: 2, stepBits: 1, leafMax: 8, interiorMax: 4 }, 'tiny') + +const entry = (key: string, v = 0): RecordEntry => ({ + key, + hash: sha256Hex(`${key}#${v}`), + size: key.length + v, +}) + +const sorted = (es: RecordEntry[]) => es.slice().sort((a, b) => compareUtf8(a.key, b.key)) + +/** A store shared across builds, the way R2 is shared across versions. */ +function store() { + const sink = new MemorySink() + const source = new MapSource(recordTree, sink.nodes, sink.leaves) + return { sink, source } +} + +async function collect(it: AsyncIterable): Promise { + const out: T[] = [] + for await (const x of it) out.push(x) + return out +} + +// Mostly ASCII, so keys collide and share prefixes; the rest any code point +// (accents, combining marks, CJK, emoji, astral planes), where UTF-8 byte order +// and UTF-16 order differ. +const keyArb = fc.oneof( + { weight: 3, arbitrary: fc.string({ unit: 'grapheme-ascii', minLength: 1, maxLength: 6 }) }, + { weight: 1, arbitrary: fc.string({ unit: 'grapheme', minLength: 1, maxLength: 4 }) }, + { weight: 1, arbitrary: fc.string({ unit: 'binary', minLength: 1, maxLength: 4 }) }, +) +const keySet = (max: number) => fc.uniqueArray(keyArb, { maxLength: max }) + +describe('build', () => { + it('gives an empty tree a null root and one leaf as its own root', () => { + const { sink } = store() + expect(buildTree(recordTree, sink, [])).toBe(null) + const root = buildTree(recordTree, sink, [entry('a'), entry('b')])! + expect(root.level).toBe(0) + expect(root.count).toBe(2) + }) + + it('builds deep trees that verify, with counts and bytes summed', async () => { + const { sink, source } = store() + const es = sorted(Array.from({ length: 2000 }, (_, i) => entry(`k${i}`, i % 7))) + const root = buildTree(recordTree, sink, es, { chunking: tiny })! + expect(root.level).toBeGreaterThan(3) + expect(root.count).toBe(2000) + expect(root.bytes).toBe(es.reduce((s, e) => s + e.size, 0)) + const v = await verifyTree(source, root.hash, { chunking: tiny }) + expect(v.errors).toEqual([]) + expect(v.count).toBe(2000) + }) + + it('refuses keys out of order', () => { + const { sink } = store() + expect(() => buildTree(recordTree, sink, [entry('b'), entry('a')])).toThrow(/out of order/) + expect(() => buildTree(recordTree, sink, [entry('a'), entry('a')])).toThrow(/out of order/) + }) + + it('verify rejects a tree built under different chunking', async () => { + const { sink, source } = store() + const es = sorted(Array.from({ length: 300 }, (_, i) => entry(`k${i}`))) + const root = buildTree(recordTree, sink, es, { chunking: tiny })! + const v = await verifyTree(source, root.hash, { chunking: protocolChunking }) + expect(v.ok).toBe(false) + expect(v.errors.join()).toMatch(/not canonical/) + }) +}) + +describe('merge', () => { + /** Apply changes to a key→entry map: the expected result of a merge. */ + const apply = (base: Map, changes: Change[]) => { + const m = new Map(base) + for (const c of changes) { + if (c.entry) m.set(c.key, c.entry) + else m.delete(c.key) + } + return sorted([...m.values()]) + } + + const changeArb = (pool: string[]) => + fc.uniqueArray( + fc.record({ + key: pool.length > 0 ? fc.oneof(fc.constantFrom(...pool), keyArb) : keyArb, + del: fc.boolean(), + v: fc.integer({ min: 0, max: 3 }), + }), + { selector: (c) => c.key, maxLength: 80 }, + ) + + it('an incremental merge equals a full rebuild (property)', async () => { + await fc.assert( + fc.asyncProperty( + keySet(400).chain((keys) => fc.tuple(fc.constant(keys), changeArb(keys))), + async ([keys, raw]) => { + const { sink, source } = store() + const base = sorted(keys.map((k) => entry(k))) + const baseRoot = buildTree(recordTree, sink, base, { chunking: tiny }) + const changes = raw + .map((c) => ({ key: c.key, entry: c.del ? null : entry(c.key, c.v) })) + .sort((a, b) => compareUtf8(a.key, b.key)) + const merged = await mergeTree(source, sink, baseRoot?.hash ?? null, changes, { + chunking: tiny, + }) + const want = buildTree( + recordTree, + new MemorySink(), + apply(new Map(base.map((e) => [e.key, e])), changes), + { + chunking: tiny, + }, + ) + expect(merged.root?.hash ?? null).toBe(want?.hash ?? null) + expect(merged.root?.count ?? 0).toBe(want?.count ?? 0) + if (merged.root) { + const v = await verifyTree(source, merged.root.hash, { chunking: tiny }) + expect(v.errors).toEqual([]) + } + }, + ), + { numRuns: 300 * RUNS }, + ) + }) + + it('random insertion orders converge on one root (property)', async () => { + await fc.assert( + fc.asyncProperty( + keySet(300), + fc.integer({ min: 1, max: 6 }), + fc.integer(), + async (keys, parts, seed) => { + const { sink, source } = store() + // Split the keys into `parts` batches pseudo-randomly and merge them in one by one. + const batches: string[][] = Array.from({ length: parts }, () => []) + keys.forEach((k, i) => batches[Math.abs((seed ^ (i * 2654435761)) % parts)]!.push(k)) + let root: string | null = null + for (const b of batches) { + const changes = b + .map((k) => ({ key: k, entry: entry(k) })) + .sort((x, y) => compareUtf8(x.key, y.key)) + root = + (await mergeTree(source, sink, root, changes, { chunking: tiny })).root?.hash ?? null + } + const want = buildTree(recordTree, new MemorySink(), sorted(keys.map((k) => entry(k))), { + chunking: tiny, + }) + expect(root).toBe(want?.hash ?? null) + }, + ), + { numRuns: 200 * RUNS }, + ) + }) + + it('deleting everything gives null; a no-op merge returns the base', async () => { + const { sink, source } = store() + const es = sorted(Array.from({ length: 200 }, (_, i) => entry(`k${i}`))) + const root = buildTree(recordTree, sink, es, { chunking: tiny })! + const none = await mergeTree(source, sink, root.hash, [], { chunking: tiny }) + expect(none.root?.hash).toBe(root.hash) + const same = await mergeTree( + source, + sink, + root.hash, + es.slice(0, 50).map((e) => ({ key: e.key, entry: e })), + { + chunking: tiny, + }, + ) + expect(same.root?.hash).toBe(root.hash) + expect(same.stats.unchanged).toBe(50) + const all = await mergeTree( + source, + sink, + root.hash, + es.map((e) => ({ key: e.key, entry: null })), + { + chunking: tiny, + }, + ) + expect(all.root).toBe(null) + expect(all.stats.removed).toBe(200) + }) + + it('collapses to a reused single-child subtree correctly', async () => { + // Keep only the keys under one base subtree; the new root must be found by + // walking down single-child chains inside reused nodes. + for (let n = 50; n < 400; n += 37) { + const { sink, source } = store() + const es = sorted(Array.from({ length: n }, (_, i) => entry(`k${i}`))) + const root = buildTree(recordTree, sink, es, { chunking: tiny })! + for (const keep of [1, 3, 10, n >> 1]) { + const drop = es.slice(keep).map((e) => ({ key: e.key, entry: null })) + const merged = await mergeTree(source, sink, root.hash, drop, { chunking: tiny }) + const want = buildTree(recordTree, new MemorySink(), es.slice(0, keep), { chunking: tiny }) + expect(merged.root?.hash).toBe(want?.hash) + } + } + }) + + it('costs O(changes): one update in a large tree reads one path', async () => { + const { sink, source } = store() + const es = sorted( + Array.from({ length: 200_000 }, (_, i) => entry(`id${String(i).padStart(7, '0')}`)), + ) + const root = buildTree(recordTree, sink, es, { chunking: protocolChunking })! + expect(root.level).toBeGreaterThanOrEqual(1) + const target = es[123_456]! + let written = 0 + const counting = { + leaf: (...a: Parameters) => { + written++ + sink.leaf(...a) + }, + interior: (...a: Parameters) => { + written++ + sink.interior(...a) + }, + } + const merged = await mergeTree(source, counting, root.hash, [ + { key: target.key, entry: entry(target.key, 9) }, + ]) + expect(merged.stats.updated).toBe(1) + // Root, maybe one interior level, one leaf (plus at most a neighbour to re-align). + expect(merged.stats.readNodes).toBeLessThanOrEqual(2 * (root.level + 1) + 1) + expect(written).toBeLessThanOrEqual(2 * (root.level + 1)) + expect(merged.stats.reusedNodes).toBeGreaterThan(0) + }) + + it('reports changes in key order through onChange', async () => { + const { sink, source } = store() + const root = buildTree(recordTree, sink, sorted(['a', 'b', 'c'].map((k) => entry(k))), { + chunking: tiny, + })! + const seen: string[] = [] + await mergeTree( + source, + sink, + root.hash, + [ + { key: 'a', entry: null }, + { key: 'b', entry: entry('b', 1) }, + { key: 'c', entry: entry('c') }, + { key: 'd', entry: entry('d') }, + { key: 'e', entry: null }, + ], + { + chunking: tiny, + onChange: (before, after) => seen.push(`${before?.key ?? '-'}>${after?.key ?? '-'}`), + }, + ) + expect(seen).toEqual(['a>-', 'b>b', '->d']) + }) +}) + +describe('parallel commit', () => { + /** Natural leaf boundaries under `tiny`: the keys a planner may split at. */ + const natural = (k: string) => trailingZeros(boundaryBytes(k)) >= 2 + + /** Run one unit: a range merge with its leaves collected. */ + const runUnit = async ( + source: NodeSource, + sink: TreeSink, + base: string | null, + range: KeyRange, + changes: Change[], + ) => { + const leaves: NodeDesc[] = [] + const r = await mergeTree( + source, + sink, + base, + changes.filter((c) => inRange(range, c.key)), + { chunking: tiny, range, leafOutput: (d) => leaves.push(d) }, + ) + // Every output leaf lies inside the range (the coordinator's fallback would + // otherwise hide a unit that spills over its edges). + for (const leaf of leaves) { + for (const e of await source.leafEntries(leaf.hash)) expect(inRange(range, e.key)).toBe(true) + } + return { ...r, leaves, survived: range.through === null || r.lastKey === range.through } + } + + /** + * A coordinator in miniature: split at `splits`, leave change-free ranges in + * `gaps` to assembly, run the rest as units, merge a unit whose `through` was + * deleted with the next range, then assemble. + */ + const parallel = async ( + source: NodeSource, + sink: TreeSink, + base: string | null, + changes: Change[], + splits: string[], + gap: (i: number) => boolean, + ) => { + const bounds = [null, ...splits, null] + let ranges = bounds.slice(1).map((through, i) => { + const range = { after: bounds[i]!, through } + return { ...range, gap: gap(i) && !changes.some((c) => inRange(range, c.key)) } + }) + for (;;) { + const segments: Segment[] = [] + const stats = { added: 0, removed: 0, updated: 0, unchanged: 0, missingDeletes: 0 } + let failed = -1 + for (let i = 0; i < ranges.length && failed < 0; i++) { + const r = ranges[i]! + if (r.gap) continue + const unit = await runUnit(source, sink, base, r, changes) + if (!unit.survived) { + // Only a deleted split key may fail a unit. + expect(changes.some((c) => c.key === r.through && c.entry === null)).toBe(true) + failed = i + } + segments.push({ after: r.after, through: r.through, leaves: unit.leaves }) + for (const k of Object.keys(stats) as (keyof typeof stats)[]) stats[k] += unit.stats[k] + } + if (failed >= 0) { + const [a, b] = [ranges[failed]!, ranges[failed + 1]!] + ranges = [ + ...ranges.slice(0, failed), + { after: a.after, through: b.through, gap: false }, + ...ranges.slice(failed + 2), + ] + continue + } + const assembled = await assembleTree(source, sink, base, segments, { chunking: tiny }) + return { root: assembled.root, stats, assembly: assembled.stats, units: segments.length } + } + } + + // fast-check keeps arrays near ten elements unless told otherwise; units and + // straddled leaves need trees several levels deep. + const bigKeySet = fc.uniqueArray(keyArb, { maxLength: 400, size: 'max' }) + const changeArb = (pool: string[]) => + fc.uniqueArray( + fc.record({ + key: pool.length > 0 ? fc.oneof(fc.constantFrom(...pool), keyArb) : keyArb, + del: fc.boolean(), + v: fc.integer({ min: 0, max: 3 }), + }), + { selector: (c) => c.key, maxLength: 80, size: 'large' }, + ) + + it( + 'units plus assembly equal the serial merge (property)', + async () => { + await fc.assert( + fc.asyncProperty( + bigKeySet.chain((keys) => fc.tuple(fc.constant(keys), changeArb(keys))), + fc.array(fc.boolean(), { minLength: 1, maxLength: 16 }), + fc.array(fc.boolean(), { minLength: 1, maxLength: 16 }), + async ([keys, raw], pick, gaps) => { + const { sink, source } = store() + const baseRoot = + buildTree(recordTree, sink, sorted(keys.map((k) => entry(k))), { chunking: tiny }) + ?.hash ?? null + const changes = raw + .map((c) => ({ key: c.key, entry: c.del ? null : entry(c.key, c.v) })) + .sort((a, b) => compareUtf8(a.key, b.key)) + // Candidates as a planner sees them: natural keys of the base and of the + // upserts. Some may be deleted by the changes; units must catch that. + const upserts = changes.filter((c) => c.entry).map((c) => c.key) + const candidates = [...new Set([...keys, ...upserts])].filter(natural).sort(compareUtf8) + const splits = candidates.filter((_, i) => pick[i % pick.length]) + const serial = await mergeTree(source, sink, baseRoot, changes, { chunking: tiny }) + const par = await parallel( + source, + sink, + baseRoot, + changes, + splits, + (i) => gaps[i % gaps.length]!, + ) + expect(par.root?.hash ?? null).toBe(serial.root?.hash ?? null) + expect(par.root?.count ?? 0).toBe(serial.root?.count ?? 0) + expect(par.root?.bytes ?? 0).toBe(serial.root?.bytes ?? 0) + const { added, removed, updated, unchanged, missingDeletes } = serial.stats + expect(par.stats).toEqual({ added, removed, updated, unchanged, missingDeletes }) + if (par.root) { + const v = await verifyTree(source, par.root.hash, { chunking: tiny }) + expect(v.errors).toEqual([]) + } + }, + ), + { numRuns: 300 * RUNS }, + ) + }, + 20_000 * RUNS, + ) + + it('appends past the end of the base in their own unit', async () => { + // A split at the base's last key with records appended after it: assembly + // must not reuse the base's right-edge nodes whole. + for (let n = 20; n < 400; n += 13) { + const { sink, source } = store() + const keys = Array.from({ length: n }, (_, i) => `k${String(i).padStart(4, '0')}`) + const last = keys.filter(natural).at(-1)! + const base = sorted(keys.filter((k) => compareUtf8(k, last) <= 0).map((k) => entry(k))) + const root = buildTree(recordTree, sink, base, { chunking: tiny })!.hash + const changes = keys + .filter((k) => compareUtf8(k, last) > 0) + .map((k) => ({ key: k, entry: entry(k) })) + if (changes.length === 0) continue + const serial = await mergeTree(source, sink, root, changes, { chunking: tiny }) + const par = await parallel(source, sink, root, changes, [last], (i) => i === 0) + expect(par.root?.hash).toBe(serial.root?.hash) + } + }) + + it('a unit reports a deleted through', async () => { + const { sink, source } = store() + const es = sorted(Array.from({ length: 300 }, (_, i) => entry(`k${i}`))) + const root = buildTree(recordTree, sink, es, { chunking: tiny })!.hash + const splits = es.map((e) => e.key).filter(natural) + const [after, through, next] = [splits[3]!, splits[4]!, splits[5]!] + const changes = [{ key: through, entry: null }] + const unit = await runUnit(source, sink, root, { after, through }, changes) + expect(unit.survived).toBe(false) + expect(unit.lastKey).not.toBe(through) + expect(unit.stats.removed).toBe(1) + const pair = await runUnit(source, sink, root, { after, through: next }, changes) + expect(pair.survived).toBe(true) + }) + + it('refuses a change outside the unit range', async () => { + const { sink, source } = store() + const range = { after: 'b', through: 'd' } + await expect( + mergeTree(source, sink, null, [{ key: 'e', entry: entry('e') }], { + chunking: tiny, + range, + leafOutput: () => {}, + }), + ).rejects.toThrow(/outside/) + }) + + it('assembly reads O(segments) nodes in a large tree', async () => { + const { sink, source } = store() + const es = sorted( + Array.from({ length: 200_000 }, (_, i) => entry(`id${String(i).padStart(7, '0')}`)), + ) + const root = buildTree(recordTree, sink, es, { chunking: protocolChunking })! + const isNatural = (k: string) => trailingZeros(boundaryBytes(k)) >= 10 + const nat = es.map((e) => e.key).filter(isNatural) + // Three units, each a few leaves wide, with one update in each. + const segments: Segment[] = [] + const changes: Change[] = [] + for (const at of [10, 80, 150]) { + const range = { after: nat[at]!, through: nat[at + 3]! } + const target = es.find((e) => compareUtf8(e.key, nat[at + 1]!) > 0)! + const unitChanges = [{ key: target.key, entry: entry(target.key, 9) }] + changes.push(...unitChanges) + const leaves: NodeDesc[] = [] + await mergeTree(source, sink, root.hash, unitChanges, { + range, + leafOutput: (d) => leaves.push(d), + }) + segments.push({ ...range, leaves }) + } + const assembled = await assembleTree(source, sink, root.hash, segments) + const serial = await mergeTree(source, sink, root.hash, changes) + expect(assembled.root?.hash).toBe(serial.root?.hash) + // A path per segment, plus the root. + expect(assembled.stats.readNodes).toBeLessThanOrEqual(3 * (root.level + 2) + 1) + }) +}) + +describe('newNodes', () => { + it( + 'lists exactly the nodes of b that a lacks, parents first (property)', + async () => { + await fc.assert( + fc.asyncProperty( + fc.uniqueArray(keyArb, { maxLength: 400, size: 'max' }), + fc.array(fc.tuple(fc.nat(), fc.boolean(), fc.integer({ min: 1, max: 3 })), { + maxLength: 60, + }), + async (keys, edits) => { + const { sink, source } = store() + const base = sorted(keys.map((k) => entry(k))) + const a = buildTree(recordTree, sink, base, { chunking: tiny })?.hash ?? null + const changes = new Map>() + for (const [i, del, v] of edits) { + const k = keys.length > 0 && i % 3 !== 0 ? keys[i % keys.length]! : `new${i}` + changes.set(k, { key: k, entry: del ? null : entry(k, v) }) + } + const merged = await mergeTree( + source, + sink, + a, + [...changes.values()].sort((x, y) => compareUtf8(x.key, y.key)), + { chunking: tiny }, + ) + const b = merged.root?.hash ?? null + const all = async (root: string | null) => { + const out = new Set() + const walk = async (h: string) => { + out.add(h) + const n = await source.node(h) + if (n.kind === 'node') for (const c of n.children) await walk(c.hash) + } + if (root) await walk(root) + return out + } + const inA = await all(a) + const inB = await all(b) + const listed: string[] = [] + const seen = new Set() + for await (const n of newNodes(source, a, b)) { + listed.push(n.hash) + seen.add(n.hash) + // Parents first: a listed node's children come later, never earlier. + const node = await source.node(n.hash) + if (node.kind === 'node') + for (const c of node.children) expect(seen.has(c.hash)).toBe(false) + } + // Everything b needs that a lacks is listed, and nothing outside b. + for (const h of inB) if (!inA.has(h)) expect(seen.has(h)).toBe(true) + for (const h of listed) expect(inB.has(h)).toBe(true) + }, + ), + { numRuns: 200 * RUNS }, + ) + }, + 5_000 * RUNS, + ) +}) + +describe('newNodes resumed', () => { + it( + 'stopping after any leaf and resuming still lists everything (property)', + async () => { + await fc.assert( + fc.asyncProperty( + fc.uniqueArray(keyArb, { maxLength: 300, size: 'max' }), + fc.array(fc.tuple(fc.nat(), fc.boolean()), { maxLength: 60 }), + fc.nat(), + async (keys, edits, pick) => { + const { sink, source } = store() + const a = + buildTree(recordTree, sink, sorted(keys.map((k) => entry(k))), { chunking: tiny }) + ?.hash ?? null + const changes = new Map>() + for (const [i, del] of edits) { + const k = keys.length > 0 && i % 3 !== 0 ? keys[i % keys.length]! : `new${i}` + changes.set(k, { key: k, entry: del ? null : entry(k, 1) }) + } + const b = + ( + await mergeTree( + source, + sink, + a, + [...changes.values()].sort((x, y) => compareUtf8(x.key, y.key)), + { chunking: tiny }, + ) + ).root?.hash ?? null + const full: { hash: string; level: number; lastKey: string }[] = [] + for await (const n of newNodes(source, a, b)) full.push(n) + const leaves = full.filter((n) => n.level === 0) + if (leaves.length === 0) return + const stop = leaves[pick % leaves.length]! + const cut = full.indexOf(stop) + 1 + const rest: string[] = [] + for await (const n of newNodes(source, a, b, { after: stop.lastKey })) rest.push(n.hash) + // Together the two halves list everything b has that a lacks, and nothing + // outside b. (The resumed half can be leaner: it may match a base node the + // full walk re-sent because the tree changed height.) + const all = async (root: string | null) => { + const out = new Set() + const walk = async (h: string) => { + out.add(h) + const n = await source.node(h) + if (n.kind === 'node') for (const c of n.children) await walk(c.hash) + } + if (root) await walk(root) + return out + } + const inA = await all(a) + const inB = await all(b) + const sent = new Set([...full.slice(0, cut).map((n) => n.hash), ...rest]) + for (const h of inB) if (!inA.has(h)) expect(sent.has(h)).toBe(true) + for (const h of sent) expect(inB.has(h)).toBe(true) + }, + ), + { numRuns: 300 * RUNS }, + ) + }, + 5_000 * RUNS, + ) +}) + +describe('read', () => { + it('looks up, iterates from a key or an offset, and diffs (property)', async () => { + await fc.assert( + fc.asyncProperty(keySet(300), fc.nat(), fc.nat(), async (keys, at, edits) => { + const { sink, source } = store() + const es = sorted(keys.map((k) => entry(k))) + const root = buildTree(recordTree, sink, es, { chunking: tiny })?.hash ?? null + // Point lookups. + for (const e of es.slice(0, 20)) + expect((await getEntry(source, root, e.key))?.hash).toBe(e.hash) + expect(await getEntry(source, root, '\u{10FFFF}missing')).toBe(null) + // Offset and keyset pagination agree with the sorted array. + const offset = es.length === 0 ? 0 : at % (es.length + 1) + const fromOffset = await collect(iterate(source, root, { offset, prefetch: 3 })) + expect(fromOffset.map((e) => e.key)).toEqual(es.slice(offset).map((e) => e.key)) + if (offset > 0) { + const after = es[offset - 1]!.key + const fromKey = await collect(iterate(source, root, { after })) + expect(fromKey.map((e) => e.key)).toEqual(es.slice(offset).map((e) => e.key)) + } + // Diff against an edited copy reports exactly the edits. + const changes: Change[] = [] + es.forEach((e, i) => { + if ((i * 7 + edits) % 11 === 0) changes.push({ key: e.key, entry: null }) + else if ((i * 5 + edits) % 13 === 0) changes.push({ key: e.key, entry: entry(e.key, 1) }) + }) + const merged = await mergeTree(source, sink, root, changes, { chunking: tiny }) + const diff = await collect(diffTrees(source, root, merged.root?.hash ?? null)) + expect(diff.map((d) => `${d.key}:${d.before ? 1 : 0}${d.after ? 1 : 0}`)).toEqual( + changes.map((c) => `${c.key}:1${c.entry ? 1 : 0}`), + ) + // Resuming past any key gives the rest of the same diff. + const resume = keys.length === 0 ? 'm' : keys[at % keys.length]! + const rest = await collect( + diffTrees(source, root, merged.root?.hash ?? null, { after: resume }), + ) + expect(rest.map((d) => d.key)).toEqual( + diff.map((d) => d.key).filter((k) => compareUtf8(k, resume) > 0), + ) + }), + { numRuns: 200 * RUNS }, + ) + }) +}) + +describe('diffTrees resumed', () => { + it('skips the subtrees before the resume key unread', async () => { + const { sink, source } = store() + const es = Array.from({ length: 4000 }, (_, i) => entry(`k${String(i).padStart(5, '0')}`)) + const root = buildTree(recordTree, sink, es, { chunking: tiny })!.hash + // Every entry changes, so the walk reads every node of both trees. + const changed = es.map((e) => ({ key: e.key, entry: entry(e.key, 1) })) + const merged = (await mergeTree(source, sink, root, changed, { chunking: tiny })).root!.hash + let reads = 0 + const counted = Object.assign(Object.create(source) as typeof source, { + node: (h: string) => { + reads++ + return source.node(h) + }, + }) + const all = await collect(diffTrees(counted, root, merged)) + const full = reads + reads = 0 + const tail = await collect(diffTrees(counted, root, merged, { after: 'k03900' })) + expect(all).toHaveLength(4000) + expect(tail).toHaveLength(99) + expect(reads).toBeLessThan(full / 10) + }) +}) + +describe('rank and offset seeks', () => { + it('rankOf and entryAt agree with the sorted entries (property)', async () => { + await fc.assert( + fc.asyncProperty(keySet(300), keyArb, fc.nat(), async (keys, probe, at) => { + const { sink, source } = store() + const es = sorted(keys.map((k) => entry(k))) + const root = buildTree(recordTree, sink, es, { chunking: tiny })?.hash ?? null + const want = es.filter((e) => compareUtf8(e.key, probe) < 0).length + expect(await rankOf(source, root, probe)).toBe(want) + const i = es.length === 0 ? 0 : at % (es.length + 1) + expect((await entryAt(source, root, i))?.key ?? null).toBe(es[i]?.key ?? null) + }), + { numRuns: 200 * RUNS }, + ) + }) +}) diff --git a/packages/protocol/test/validate.test.ts b/packages/protocol/test/validate.test.ts new file mode 100644 index 0000000..bf22ed6 --- /dev/null +++ b/packages/protocol/test/validate.test.ts @@ -0,0 +1,534 @@ +import { describe, expect, it } from 'vitest' + +import { + checkSchema, + checkSchemaBounds, + checkSchemaFull, + compileSchema, + InputRuleError, + findExtraFields, + MAX_SCHEMA_BYTES, + SchemaError, + stripToSchema, +} from '../src/index.js' + +const errors = (schema: unknown, data: unknown) => compileSchema(schema)(data) +const valid = (schema: unknown, data: unknown) => errors(schema, data).length === 0 +const compileError = (schema: unknown): string | null => { + try { + compileSchema(schema) + return null + } catch (err) { + expect(err).toBeInstanceOf(SchemaError) + return (err as Error).message + } +} + +describe('compileSchema', () => { + it('reuses the validator for identical schema content', () => { + const a = compileSchema({ type: 'object', properties: { x: { type: 'string' } } }) + const b = compileSchema({ properties: { x: { type: 'string' } }, type: 'object' }) + expect(b).toBe(a) + }) + + it('does not modify the schema it is given', () => { + const schema = { + definitions: { a: { type: 'string' } }, + properties: { x: { $ref: '#/definitions/a' } }, + } + const before = JSON.stringify(schema) + compileSchema(schema)({ x: 1 }) + expect(JSON.stringify(schema)).toBe(before) + expect(Object.getOwnPropertyNames(schema.properties.x)).toEqual(['$ref']) + }) + + it('reports errors as " ", in AJV wording', () => { + const schema = { + type: 'object', + required: ['id', 'name'], + properties: { + id: { type: 'integer' }, + tags: { type: 'array', items: { type: 'string' }, maxItems: 2 }, + nested: { type: 'object', properties: { 'a/b': { type: 'string' } } }, + }, + } + expect(errors(schema, { id: 1, name: 'x' })).toEqual([]) + expect(errors(schema, { id: 1.5, tags: ['a', 2, 'c'], nested: { 'a/b': 3 } }).sort()).toEqual( + [ + "/ must have required property 'name'", + '/id must be integer', + '/tags must NOT have more than 2 items', + '/tags/1 must be string', + '/nested/a~1b must be string', + ].sort(), + ) + expect(errors({ type: ['string', 'null'] }, 1)).toEqual(['/ must be string,null']) + }) + + it('checks required and additionalProperties', () => { + const schema = { required: ['a'], properties: { a: {} }, additionalProperties: false } + expect(valid(schema, { a: 1 })).toBe(true) + expect(errors(schema, {})).toEqual(["/ must have required property 'a'"]) + expect(errors(schema, { a: 1, b: 2 })).toEqual(['/ must NOT have additional properties']) + // A subschema for additional properties applies to each of them. + expect(errors({ additionalProperties: { type: 'number' } }, { a: 1, b: 'x' })).toEqual([ + '/b must be number', + ]) + }) + + it('checks enum, const, numbers and strings', () => { + expect(valid({ enum: ['a', 1, null, { k: [1] }] }, { k: [1] })).toBe(true) + expect(errors({ enum: ['a', 1] }, 'b')).toEqual([ + '/ must be equal to one of the allowed values', + ]) + expect(errors({ const: 'a' }, 'b')).toEqual(['/ must be equal to constant']) + expect(errors({ minimum: 2, maximum: 5 }, 1)).toEqual(['/ must be >= 2']) + expect(errors({ exclusiveMaximum: 5 }, 5)).toEqual(['/ must be < 5']) + expect(errors({ minLength: 2 }, 'a')).toEqual(['/ must NOT have fewer than 2 characters']) + expect(errors({ pattern: '^[a-z]+$' }, 'A')).toEqual(['/ must match pattern "^[a-z]+$"']) + // Patterns are Unicode regexes, as in AJV: \p{…} works. + expect(valid({ pattern: '^\\p{L}+$' }, 'été')).toBe(true) + }) + + it('checks the formats used in practice', () => { + const cases: [string, string, boolean][] = [ + ['date', '2024-02-29', true], + ['date', '2023-02-29', false], + ['date', '2024-1-01', false], + ['date-time', '2024-01-01T12:00:00Z', true], + ['date-time', '2024-01-01T12:00:00.5+01:00', true], + ['date-time', '2024-01-01 12:00:00Z', true], + ['date-time', '2024-01-01T12:00:00', false], // no time zone + ['date-time', '2024-01-01', false], + ['email', 'a.b+c@example.org', true], + ['email', 'a@localhost', false], // AJV requires a dot in the domain + ['email', 'not an email', false], + ['uri', 'https://example.org/a?b=c#d', true], + ['uri', 'urn:isbn:0451450523', true], + ['uri', '/relative/path', false], + ['uri', 'example.org', false], + ['uuid', '550e8400-e29b-41d4-a716-446655440000', true], + ['uuid', 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', true], + ['uuid', '550e8400e29b41d4a716446655440000', false], + ] + for (const [format, value, ok] of cases) { + expect([format, value, valid({ type: 'string', format }, value)]).toEqual([format, value, ok]) + } + expect(errors({ format: 'date' }, 'x')).toEqual(['/ must match format "date"']) + // Formats constrain strings only; other types pass. + expect(valid({ format: 'date' }, 5)).toBe(true) + }) + + it("leaves the library's own format table alone", async () => { + const lib = await import('@cfworker/json-schema') + const before = { ...lib.format } as Record + expect(valid({ type: 'string', format: 'byte' }, '!!')).toBe(false) + expect(valid({ type: 'string', format: 'date-time' }, '2020-01-01T00:00:00')).toBe(false) + // Our definitions sit beside the library's under their own names. + for (const [k, v] of Object.entries(before)) expect(lib.format[k as 'date']).toBe(v) + expect('byte' in lib.format).toBe(false) + expect(Object.getPrototypeOf(lib.format)).toBe(Object.prototype) + expect(new lib.Validator({ type: 'string', format: 'byte' }).validate('!!').valid).toBe(true) + }) + + it('ignores unknown formats', () => { + expect(valid({ type: 'string', format: 'not-a-format' }, 'anything')).toBe(true) + }) + + it('resolves $ref to #/definitions and #/$defs, and applies keywords next to $ref', () => { + const schema = { + definitions: { name: { type: 'string', minLength: 1 } }, + $defs: { count: { type: 'integer', minimum: 0 } }, + properties: { + name: { $ref: '#/definitions/name' }, + count: { $ref: '#/$defs/count' }, + short: { $ref: '#/definitions/name', maxLength: 3 }, + }, + } + expect(valid(schema, { name: 'a', count: 0, short: 'abc' })).toBe(true) + expect(errors(schema, { name: '', count: -1, short: 'abcd' }).sort()).toEqual( + [ + '/count must be >= 0', + '/name must NOT have fewer than 1 characters', + '/short must NOT have more than 3 characters', + ].sort(), + ) + }) + + it('handles a root $ref with sibling definitions (a production schema shape)', () => { + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://example.org/schema.json', + $ref: '#/definitions/Doc', + definitions: { + Doc: { type: 'object', required: ['type'], properties: { type: { enum: ['Doc'] } } }, + }, + } + expect(valid(schema, { type: 'Doc' })).toBe(true) + expect(errors(schema, { type: 'x' })).toEqual([ + '/type must be equal to one of the allowed values', + ]) + }) + + it('validates recursive schemas', () => { + const schema = { + definitions: { + node: { + type: 'object', + properties: { children: { type: 'array', items: { $ref: '#/definitions/node' } } }, + }, + }, + $ref: '#/definitions/node', + } + expect(valid(schema, { children: [{ children: [] }] })).toBe(true) + expect(errors(schema, { children: [{ children: [5] }] })).toEqual([ + '/children/0/children/0 must be object', + ]) + }) + + it('ignores unknown keywords, like AJV with strict: false', () => { + const schema = { + type: 'object', + 'x-ref-type': 'Community', + version: '1.0', + properties: { a: { type: 'string', 'x-ui': { widget: 'text' }, private: false } }, + } + expect(valid(schema, { a: 'x' })).toBe(true) + }) + + it('ignores keywords from later drafts, as draft-07 does', () => { + expect(valid({ properties: { a: {} }, unevaluatedProperties: false }, { b: 1 })).toBe(true) + expect(valid({ dependentRequired: { a: ['b'] } }, { a: 1 })).toBe(true) + expect(valid({ prefixItems: [{ type: 'string' }] }, [1])).toBe(true) + expect(valid({ contains: { type: 'string' }, minContains: 2 }, ['a'])).toBe(true) + // ...but a property that happens to be named like one is still a property. + expect(errors({ properties: { prefixItems: { type: 'string' } } }, { prefixItems: 1 })).toEqual( + ['/prefixItems must be string'], + ) + // draft-07's own `dependencies` applies. + expect(errors({ dependencies: { a: ['b'] } }, { a: 1 })).toEqual([ + '/ must have property b when property a is present', + ]) + }) + + it('accepts boolean subschemas, but not at the root', () => { + expect(valid({ properties: { a: true } }, { a: 1 })).toBe(true) + expect(errors({ properties: { a: false } }, { a: 1 })).toEqual(['/a boolean schema is false']) + expect(compileError(true)).toBe('schema must be an object') + }) + + it('treats inherited JavaScript names as ordinary property names', () => { + expect(errors({ required: ['toString'] }, {})).toEqual([ + "/ must have required property 'toString'", + ]) + expect(valid({ properties: { constructor: { type: 'string' } } }, {})).toBe(true) + expect(errors({ properties: { constructor: { type: 'string' } } }, { constructor: 1 })).toEqual( + ['/constructor must be string'], + ) + expect(errors({ dependencies: { toString: ['b'] } }, {})).toEqual([]) + expect(valid({ type: 'string', format: 'hasOwnProperty' }, 'x')).toBe(true) + // JSON.parse makes "__proto__" an ordinary key; an object literal would not. + const schema = JSON.parse( + '{"required":["__proto__"],"properties":{"__proto__":{"required":["b"]}}}', + ) + expect(errors(schema, JSON.parse('{"__proto__": {"a": 1}}'))).toEqual([ + "/__proto__ must have required property 'b'", + ]) + expect(errors(schema, {})).toEqual(["/ must have required property '__proto__'"]) + }) + + it('applies multipleOf with a tolerance, unlike AJV', () => { + // 0.07 / 0.01 is 7.000000000000001 in binary floating point; AJV (v1) + // rejected it. The tolerance is the library's: |remainder| < 1.1920929e-7. + expect(valid({ multipleOf: 0.01 }, 0.07)).toBe(true) + expect(errors({ multipleOf: 0.01 }, 0.075)).toEqual(['/ must be multiple of 0.01']) + }) + + it('resolves $ref to the draft-07 meta-schema, as AJV does', () => { + const schema = { $ref: 'http://json-schema.org/draft-07/schema#' } + expect(valid(schema, { type: 'string' })).toBe(true) + expect(valid(schema, { type: 3 })).toBe(false) + }) + + it('reports AJV-style messages for dependencies, uniqueItems and propertyNames', () => { + expect(errors({ dependencies: { a: ['b', 'c'] } }, { a: 1, c: 1 })).toEqual([ + '/ must have properties b, c when property a is present', + ]) + expect(errors({ uniqueItems: true }, [1, 2, 1, 2])).toEqual([ + '/ must NOT have duplicate items (items ## 1 and 3 are identical)', + ]) + expect(errors({ propertyNames: { maxLength: 2 } }, { abc: 1 }).sort()).toEqual([ + '/ must NOT have more than 2 characters', + '/ property name must be valid', + ]) + }) + + it('never reports an invalid record as valid, whatever its property names', () => { + // From the JSON Schema Test Suite (draft7/dependencies.json): a newline in a + // property name once stopped the message from being worded, and the error + // was dropped with it. + const v = compileSchema({ dependencies: { 'foo\nbar': ['foo\rbar'] } }) + expect(v({ 'foo\nbar': 1, foo: 2 })).toEqual([ + '/ must have property foo\rbar when property foo\nbar is present', + ]) + expect(v({ 'foo\nbar': 1, 'foo\rbar': 2 })).toEqual([]) + const r = compileSchema({ required: ['a\nb'] }) + expect(r({})).toEqual(["/ must have required property 'a\nb'"]) + }) + + it('refuses a schema nested too deeply to compile', () => { + let deep: unknown = {} + for (let i = 0; i < 20_000; i++) deep = { items: deep } + expect(compileError(deep)).toMatch(/^schema could not be compiled|^schema is invalid/) + }) + + it('rejects schemas that are not valid draft-07', () => { + expect(compileError('string')).toBe('schema must be an object') + expect(compileError({ type: 'text' })).toMatch(/^schema is invalid: /) + expect(compileError({ required: 'a' })).toMatch(/^schema is invalid: /) + expect(compileError({ pattern: '(' })).toMatch(/^schema is invalid: /) + expect(compileError({ $ref: '#/definitions/missing' })).toBe( + "can't resolve reference #/definitions/missing", + ) + }) + + it('accepts draft-07 $schema and refuses other drafts', () => { + expect(compileError({ $schema: 'http://json-schema.org/draft-07/schema#' })).toBe(null) + expect(compileError({ $schema: 'http://json-schema.org/draft-07/schema' })).toBe(null) + expect(compileError({ $schema: 'https://json-schema.org/draft/2020-12/schema' })).toMatch( + /^unsupported \$schema/, + ) + expect(compileError({ $schema: 'http://json-schema.org/draft-04/schema#' })).toMatch( + /^unsupported \$schema/, + ) + }) + + it('reports every error, except in schemas with branching keywords', () => { + const data = { a: 1, b: 2 } + const props = { a: { type: 'string' }, b: { type: 'string' } } + expect(errors({ properties: props }, data)).toEqual(['/a must be string', '/b must be string']) + // With anyOf/oneOf/not/if/contains anywhere, a properties or items loop + // stops at its first failure: reporting everything is exponential there. + expect(errors({ properties: props, not: { type: 'null' } }, data)).toEqual([ + '/a must be string', + ]) + expect(valid({ properties: props, not: { type: 'null' } }, { a: 'x', b: 'y' })).toBe(true) + }) + + it('stays fast on a deep record under a recursive oneOf schema', () => { + // The shape that made exhaustive checking exponential (a production schema). + const schema = { + $ref: '#/definitions/node', + definitions: { + node: { + oneOf: [ + { + type: 'object', + required: ['type'], + properties: { + type: { const: 'a' }, + children: { type: 'array', items: { $ref: '#/definitions/node' } }, + }, + }, + { + type: 'object', + required: ['type'], + properties: { + type: { const: 'b' }, + children: { type: 'array', items: { $ref: '#/definitions/node' } }, + }, + }, + { + type: 'object', + required: ['type'], + properties: { + type: { const: 'c' }, + children: { type: 'array', items: { $ref: '#/definitions/node' } }, + }, + }, + ], + }, + }, + } + let data: unknown = { type: 'c' } + for (let i = 0; i < 40; i++) + data = { type: i % 2 ? 'a' : 'b', children: [data, { type: 'c', x: 1 }] } + const started = performance.now() + expect(valid(schema, data)).toBe(true) + expect(valid(schema, { type: 'a', children: [{ type: 'z' }] })).toBe(false) + expect(performance.now() - started).toBeLessThan(1000) + }) + + it('reports a schema that recurses without consuming data instead of throwing', () => { + const v = compileSchema({ $ref: '#' }) + expect(v(1)[0]).toMatch(/^\/ schema could not be applied/) + }) +}) + +describe('checkSchema', () => { + it('accepts ordinary schemas, and a private type', () => { + expect( + checkSchema('Person', { type: 'object', properties: { name: { type: 'string' } } }), + ).toBe(null) + expect(checkSchema('Secret', { private: true, type: 'object' })).toBe(null) + expect(checkSchema('Open', { private: false })).toBe(null) + }) + + it('rejects field-level privacy, at any depth', () => { + expect( + checkSchema('Person', { properties: { ssn: { type: 'string', private: true } } }), + ).toMatch(/marks property "\/properties\/ssn" as private/) + expect( + checkSchema('Person', { + definitions: { a: { properties: { b: { properties: { c: { private: true } } } } } }, + }), + ).toMatch(/"\/definitions\/a\/properties\/b\/properties\/c"/) + // A property named "private", or private in a data position, is not the marker. + expect(checkSchema('T', { properties: { private: { type: 'boolean' } } })).toBe(null) + // Names in a map of subschemas are not keywords, whatever they are called. + for (const map of ['definitions', '$defs', 'properties', 'patternProperties', 'dependencies']) + for (const name of ['enum', 'const', 'default', 'examples', 'required', 'type']) + expect( + checkSchema('T', { [map]: { [name]: { properties: { s: { private: true } } } } }), + ).toMatch(`"/${map}/${name}/properties/s"`) + expect(checkSchema('T', { properties: { type: { private: true } } })).toMatch( + /"\/properties\/type"/, + ) + // Only property schemas: `private` elsewhere is an unknown keyword. + expect(checkSchema('T', { items: { private: true } })).toBe(null) + expect(checkSchema('T', { definitions: { a: { private: true } } })).toBe(null) + expect(checkSchema('T', { allOf: [{ private: true }] })).toBe(null) + expect( + checkSchema('T', { + properties: { a: { default: { properties: { b: { private: true } } } } }, + }), + ).toBe(null) + }) + + it('requires a root "private" to be a boolean', () => { + expect(checkSchema('T', { private: 'true' })).toMatch(/"private" must be a boolean/) + }) + + it('bounds size on the canonical form', () => { + const big = { description: 'x'.repeat(MAX_SCHEMA_BYTES) } + expect(checkSchema('T', big)).toMatch(/exceeds maximum size/) + // Whitespace isn't counted: the limit applies to the JCS bytes. + const fits = { description: 'x'.repeat(MAX_SCHEMA_BYTES - 20) } + expect(checkSchema('T', fits)).toBe(null) + // Multi-byte characters count as their UTF-8 length. + const wide = { description: 'é'.repeat(MAX_SCHEMA_BYTES / 2) } + expect(checkSchema('T', wide)).toMatch(/exceeds maximum size/) + }) + + it('bounds pattern length, including patternProperties keys', () => { + const long = 'a'.repeat(257) + expect(checkSchema('T', { properties: { a: { pattern: 'a'.repeat(256) } } })).toBe(null) + expect(checkSchema('T', { properties: { a: { pattern: long } } })).toMatch( + /"pattern" longer than 256/, + ) + expect(checkSchema('T', { patternProperties: { [long]: {} } })).toMatch( + /"patternProperties" pattern longer than 256/, + ) + // A `pattern` member of instance data is no regex. + for (const kw of ['const', 'default', 'examples', 'enum']) + expect( + checkSchema('T', { + properties: { + a: { + [kw]: kw === 'enum' || kw === 'examples' ? [{ pattern: long }] : { pattern: long }, + }, + }, + }), + ).toBe(null) + // …but a map entry named after one of those keywords is a schema. + for (const map of ['properties', 'definitions', '$defs', 'patternProperties', 'dependencies']) + for (const name of ['type', 'required', 'enum', 'const', 'default', 'examples']) + expect(checkSchema('T', { [map]: { [name]: { type: 'string', pattern: long } } })).toMatch( + /"pattern" longer than 256/, + ) + expect( + checkSchema('T', { definitions: { enum: { patternProperties: { [long]: {} } } } }), + ).toMatch(/"patternProperties" pattern longer than 256/) + }) + + it('checks type slugs', () => { + expect(checkSchema('a/b', {})).toMatch(/Invalid type slug "a\/b"/) + expect(checkSchema('.hidden', {})).toMatch(/Invalid type slug/) + }) + + it('checkSchemaFull applies every acceptance rule, and never throws', () => { + const ok = { type: 'object', properties: { name: { type: 'string', pattern: '^\\p{L}+$' } } } + expect(checkSchemaFull('Person', ok)).toBe(null) + // Section 5, as checkSchema. + expect(checkSchemaFull('a/b', ok)).toMatch(/Invalid type slug/) + expect(checkSchemaFull('T', { private: 1 })).toMatch(/"private" must be a boolean/) + expect(checkSchemaFull('T', { properties: { s: { private: true } } })).toMatch(/as private/) + expect(checkSchemaFull('T', { pattern: 'a'.repeat(257) })).toMatch(/longer than 256/) + // Section 5.1, which compiling checks. + expect(checkSchemaFull('T', [])).toBe('Schema "T": schema must be an object') + expect( + checkSchemaFull('T', { $schema: 'https://json-schema.org/draft/2020-12/schema' }), + ).toMatch(/^Schema "T": unsupported \$schema/) + expect(checkSchemaFull('T', { type: 'text' })).toMatch(/^Schema "T": schema is invalid/) + expect(checkSchemaFull('T', { pattern: '\\p{Foo}' })).toMatch( + /^Schema "T": pattern .* is invalid/, + ) + expect(checkSchemaFull('T', { patternProperties: { '(? { + let err: unknown + try { + compileSchema({ type: 'text' }) + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(SchemaError) + expect(err).not.toBeInstanceOf(InputRuleError) + expect((err as Error).name).toBe('SchemaError') + }) + + it('checkSchemaBounds returns the first error in a set', () => { + expect(checkSchemaBounds({ A: {}, B: { type: 'object' } })).toBe(null) + expect(checkSchemaBounds({ A: {}, B: { properties: { x: { private: true } } } })).toMatch( + /^Schema "B"/, + ) + }) +}) + +describe('extra fields', () => { + const schemas = { T: { properties: { a: {}, b: {} } }, Open: {} } + + it('findExtraFields lists top-level fields not in properties', () => { + expect( + findExtraFields( + [ + { recordId: '1', type: 'T', data: { a: 1, c: 2, d: 3 } }, + { recordId: '2', type: 'T', data: { a: 1 } }, + { recordId: '3', type: 'Open', data: { z: 1 } }, + { recordId: '4', type: 'T', data: 'scalar' }, + { recordId: '5', type: 'T', data: { toString: 1 } }, + ], + schemas, + ), + ).toEqual([ + { recordId: '1', type: 'T', fields: ['c', 'd'] }, + { recordId: '5', type: 'T', fields: ['toString'] }, + ]) + }) + + it('stripToSchema keeps only listed top-level fields', () => { + expect(stripToSchema({ a: 1, c: 2, b: { x: 1 } }, { a: {}, b: {} })).toEqual({ + a: 1, + b: { x: 1 }, + }) + expect(stripToSchema({ constructor: 1 }, {})).toEqual({}) + }) +}) diff --git a/packages/protocol/test/vectors.test.ts b/packages/protocol/test/vectors.test.ts new file mode 100644 index 0000000..45da589 --- /dev/null +++ b/packages/protocol/test/vectors.test.ts @@ -0,0 +1,61 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { checkSchemaFull, InputRuleError, parseRecordLine, sha256Hex } from '../src/index.js' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +interface Verdict { + ok: boolean + error?: string + hash?: string +} +const vectors = JSON.parse(readFileSync(resolve(root, 'test/vectors/v2.json'), 'utf8')) as { + inputRules: (Verdict & { line: string; canonical?: string })[] + inputRuleRecipes: (Verdict & { prefix: string; repeat: string; n: number; suffix: string })[] + schemaRules: { note: string; slug: string; schema: unknown; ok: boolean }[] +} + +/** The verdict of the input rules on a line, in the vectors' terms. */ +function verdict(line: string): Verdict & { canonical?: string } { + try { + const { canonical } = parseRecordLine(line) + return { ok: true, canonical, hash: sha256Hex(canonical) } + } catch (err) { + if (!(err instanceof InputRuleError)) throw err + return { ok: false, error: err.code } + } +} + +describe('protocol test vectors', () => { + // The vectors are the protocol's contract with other implementations. If this + // fails, the implementation changed a hash, a tree shape or an input rule. + it('test/vectors/v2.json matches the implementation', () => { + const out = execFileSync('npx', ['tsx', 'scripts/gen-vectors.ts', '--check'], { + cwd: root, + encoding: 'utf8', + }) + expect(out).toContain('vectors match') + }, 60_000) + + // The same checks a second implementation would make, read from the file. + it('input rules give each line its verdict', () => { + for (const { line, ...want } of vectors.inputRules) expect(verdict(line), line).toEqual(want) + }) + + it('input rules give each long line its verdict', () => { + for (const { prefix, repeat, n, suffix, ...want } of vectors.inputRuleRecipes) { + const { canonical: _, ...got } = verdict(prefix + repeat.repeat(n) + suffix) + expect(got, `${prefix}${repeat}×${n}${suffix}`).toEqual(want) + } + }) + + it('schema rules accept and reject each schema', () => { + for (const { note, slug, schema, ok } of vectors.schemaRules) + expect(checkSchemaFull(slug, schema) === null, note).toBe(ok) + }) +}) diff --git a/packages/protocol/test/vectors/v2.json b/packages/protocol/test/vectors/v2.json new file mode 100644 index 0000000..80a1f2a --- /dev/null +++ b/packages/protocol/test/vectors/v2.json @@ -0,0 +1,1177 @@ +{ + "protocolVersion": 2, + "generatedBy": "packages/protocol/scripts/gen-vectors.ts", + "constants": { + "INTERIOR_BOUNDARY_BITS_STEP": 6, + "INTERIOR_MAX_CHILDREN": 1024, + "LEAF_BOUNDARY_BITS": 10, + "LEAF_MAX_ENTRIES": 8192, + "MAX_ID_BYTES": 1024, + "MAX_JSON_DEPTH": 64, + "MAX_PATTERN_LENGTH": 256, + "MAX_RECORD_BYTES": 8388608, + "MAX_SAFE_INTEGER_LITERAL": "9007199254740991", + "MAX_SCHEMA_BYTES": 262144, + "MAX_TYPE_BYTES": 128, + "PROTOCOL_VERSION": 2, + "VERSION_HASH_PREFIX": "ulv2:" + }, + "jcs": [ + { + "input": "{\"b\":1,\"a\":2}", + "output": "{\"a\":2,\"b\":1}" + }, + { + "input": "{\"9\":3,\"10\":2,\"a\":1}", + "output": "{\"10\":2,\"9\":3,\"a\":1}" + }, + { + "input": "{\"numbers\":[333333333.33333329,1E30,4.50,2e-3,0.000000000000000000000000001,-0,1e21,1e-7]}", + "output": "{\"numbers\":[333333333.3333333,1e+30,4.5,0.002,1e-27,0,1e+21,1e-7]}" + }, + { + "input": "{\"\\u20ac\":1,\"\\r\":2,\"\\ufb33\":3,\"1\":4,\"\\ud83d\\ude00\":5,\"\\u0080\":6,\"\\u00f6\":7}", + "output": "{\"\\r\":2,\"1\":4,\"€\":6,\"ö\":7,\"€\":1,\"😀\":5,\"דּ\":3}" + }, + { + "input": "{\"s\":\"\\u0000\\u001f\\\"\\\\\\/\\b\\f\\n\\r\\t\\u007f\\u2028é😀\"}", + "output": "{\"s\":\"\\u0000\\u001f\\\"\\\\/\\b\\f\\n\\r\\t
é😀\"}" + }, + { + "input": "[[],{},[{}],null,true,false,0,\"\",[1,[2,[3]]]]", + "output": "[[],{},[{}],null,true,false,0,\"\",[1,[2,[3]]]]" + }, + { + "input": "{\"e\\u0301\":1,\"\\u00e9\":2}", + "output": "{\"é\":1,\"é\":2}" + } + ], + "inputRules": [ + { + "line": "{\"id\":\"r1\",\"type\":\"Author\",\"data\":{\"name\":\"Ada\",\"year\":1815}}", + "ok": true, + "canonical": "{\"id\":\"r1\",\"type\":\"Author\",\"data\":{\"name\":\"Ada\",\"year\":1815}}", + "hash": "adefbd10aa438f0c6ed1627817f391ac6cc0441737ee09b4ebcc30fbd8386c63" + }, + { + "line": "{\"type\":\"Author\",\"data\":{\"year\":1815,\"name\":\"Ada\"},\"id\":\"r1\",\"private\":true}", + "ok": true, + "canonical": "{\"id\":\"r1\",\"type\":\"Author\",\"data\":{\"name\":\"Ada\",\"year\":1815}}", + "hash": "adefbd10aa438f0c6ed1627817f391ac6cc0441737ee09b4ebcc30fbd8386c63" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"a\":1,\"a\":2}}", + "ok": false, + "error": "duplicate_key" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"a\":1,\"\\u0061\":2}}", + "ok": false, + "error": "duplicate_key" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"x\":{\"a\":1},\"y\":{\"a\":1}}}", + "ok": true, + "canonical": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"x\":{\"a\":1},\"y\":{\"a\":1}}}", + "hash": "edec381ac18eef8cc201472a6a9c1900fcc423eda52191d6e95cf96aeb42e3c0" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":9007199254740991}", + "ok": true, + "canonical": "{\"id\":\"r\",\"type\":\"t\",\"data\":9007199254740991}", + "hash": "5ece22156b8f1998b32f1d55690ab678ed56ef26fb77efa3ca3e7cdeceea9014" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":9007199254740992}", + "ok": false, + "error": "unsafe_integer" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":-9007199254740992}", + "ok": false, + "error": "unsafe_integer" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":[1e20,6.02e23,9007199254740993.0]}", + "ok": true, + "canonical": "{\"id\":\"r\",\"type\":\"t\",\"data\":[100000000000000000000,6.02e+23,9007199254740992]}", + "hash": "3dbe19a933d5f58f671e585d69e9a2a8821e99b21b9251d500c69dcaef8d1b99" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":\"\\ud83d\\ude00\"}", + "ok": true, + "canonical": "{\"id\":\"r\",\"type\":\"t\",\"data\":\"😀\"}", + "hash": "954aa6984679f11f06cd47ba3cf7f0cfbb90025622f4bd3b950e307e3d6c42e6" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":\"\\ud83d\"}", + "ok": false, + "error": "lone_surrogate" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":\"\\ude00\"}", + "ok": false, + "error": "lone_surrogate" + }, + { + "line": "{\"id\":\"\\u00e9\",\"type\":\"t\",\"data\":1}", + "ok": true, + "canonical": "{\"id\":\"é\",\"type\":\"t\",\"data\":1}", + "hash": "727990d1c3826f31ce44381963232c96e401f5032a93340006aac42d5c828b3b" + }, + { + "line": "{\"id\":\"e\\u0301\",\"type\":\"t\",\"data\":1}", + "ok": true, + "canonical": "{\"id\":\"é\",\"type\":\"t\",\"data\":1}", + "hash": "18e9735e8c9dacb7b9fff7fa443fc2524850405c24affa7d5cbd03c9412d5b42" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}", + "ok": true, + "canonical": "{\"id\":\"r\",\"type\":\"t\",\"data\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}", + "hash": "8ba61dc6000260fb0836c65cfdd3510a8124d231d9552e643aea595bc182c2a3" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}", + "ok": false, + "error": "too_deep" + }, + { + "line": "{\"id\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"type\":\"t\",\"data\":1}", + "ok": true, + "canonical": "{\"id\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"type\":\"t\",\"data\":1}", + "hash": "678076cecc40fc4cdd0352e4b3ec9124c8fce4830526aa1c2f20e0b4970ba5fc" + }, + { + "line": "{\"id\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"type\":\"t\",\"data\":1}", + "ok": false, + "error": "bad_id" + }, + { + "line": "{\"id\":\"\",\"type\":\"t\",\"data\":1}", + "ok": false, + "error": "bad_id" + }, + { + "line": "{\"id\":\"r\",\"type\":\"a/b\",\"data\":1}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\"}", + "ok": false, + "error": "bad_envelope" + }, + { + "line": "{\"type\":\"t\",\"data\":1}", + "ok": false, + "error": "bad_id" + }, + { + "line": "{\"id\":\"r\",\"data\":1}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":1,\"private\":\"yes\"}", + "ok": false, + "error": "bad_envelope" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":1,\"note\":\"ignored\",\"hash\":\"x\"}", + "ok": true, + "canonical": "{\"id\":\"r\",\"type\":\"t\",\"data\":1}", + "hash": "e5cb51e87c7f60c96132bd967d532a117ca7f3a167506d7bbeec4e62b76f25a1" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"9\":3,\"10\":2}}", + "ok": true, + "canonical": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"10\":2,\"9\":3}}", + "hash": "b551945866116dcfa7737f4e687c4ffac0dcd4b2da992b1fb2a74d38a72bfe05" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":}", + "ok": false, + "error": "syntax" + }, + { + "line": "not json", + "ok": false, + "error": "syntax" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":\"abc", + "ok": false, + "error": "syntax" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"\\x\":1}}", + "ok": false, + "error": "syntax" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":\"\\u12zz\"}", + "ok": false, + "error": "syntax" + }, + { + "line": "{\"id\":\"r\",\"type\":\"tttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt\",\"data\":1}", + "ok": true, + "canonical": "{\"id\":\"r\",\"type\":\"tttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt\",\"data\":1}", + "hash": "58de34201a31df6d3894dadc8f53e78253dd2a29bdfa085859c1c3fb0cf2711a" + }, + { + "line": "{\"id\":\"r\",\"type\":\"ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt\",\"data\":1}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":\"ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\",\"data\":1}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":\".t\",\"data\":1}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":\"a\\\\b\",\"data\":1}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\\u0001\",\"data\":1}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\\u007f\",\"data\":1}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":\"\",\"data\":1}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":1,\"data\":1}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}", + "ok": true, + "canonical": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}", + "hash": "786e1e92024cbce68554e5d7eb262885ab6496c11113a18d091e520f37784bfb" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{\"a\":{}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}", + "ok": false, + "error": "too_deep" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":1,\"ignored\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}", + "ok": false, + "error": "too_deep" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"a\":1,\"a\":2},}", + "ok": false, + "error": "duplicate_key" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"\\x\":1,\"a\":1,\"a\":2}}", + "ok": false, + "error": "duplicate_key" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":{\"a\":\"\\u12zz\",\"a\":1}}", + "ok": false, + "error": "syntax" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"data\":[9007199254740992,\"\\ud83d\"]}", + "ok": false, + "error": "unsafe_integer" + }, + { + "line": "[1]", + "ok": false, + "error": "bad_envelope" + }, + { + "line": "{\"id\":\"\",\"type\":\"a/b\"}", + "ok": false, + "error": "bad_id" + }, + { + "line": "{\"id\":\"r\",\"type\":\".t\"}", + "ok": false, + "error": "bad_type" + }, + { + "line": "{\"id\":\"r\",\"type\":\"t\",\"private\":1}", + "ok": false, + "error": "bad_envelope" + } + ], + "inputRuleRecipes": [ + { + "prefix": "{\"id\":\"r\",\"type\":\"t\",\"data\":\"", + "repeat": "x", + "n": 8388577, + "suffix": "\"}", + "ok": true, + "hash": "5e2d87a257d005c13b4c6e0964741cc50a7b04bb65f430e2f088c1dc7504ac52" + }, + { + "prefix": "{\"id\":\"r\",\"type\":\"t\",\"data\":\"", + "repeat": "x", + "n": 8388578, + "suffix": "\"}", + "ok": false, + "error": "record_too_large" + }, + { + "prefix": "{ \"id\" : \"r\" , \"type\" : \"t\" , \"data\" : \"", + "repeat": "x", + "n": 8388577, + "suffix": "\" }", + "ok": true, + "hash": "5e2d87a257d005c13b4c6e0964741cc50a7b04bb65f430e2f088c1dc7504ac52" + }, + { + "prefix": "{\"id\":\"r\",\"type\":\"t\",\"data\":\"", + "repeat": "é", + "n": 4194289, + "suffix": "\"}", + "ok": false, + "error": "record_too_large" + } + ], + "schemaRules": [ + { + "note": "an ordinary schema", + "slug": "T", + "schema": { + "type": "object", + "properties": { + "a": { + "type": "string" + } + } + }, + "ok": true + }, + { + "note": "a private type", + "slug": "T", + "schema": { + "private": true, + "type": "object" + }, + "ok": true + }, + { + "note": "root private false", + "slug": "T", + "schema": { + "private": false + }, + "ok": true + }, + { + "note": "root private not a boolean", + "slug": "T", + "schema": { + "private": "true" + }, + "ok": false + }, + { + "note": "root private null", + "slug": "T", + "schema": { + "private": null + }, + "ok": false + }, + { + "note": "a property called \"private\"", + "slug": "T", + "schema": { + "properties": { + "private": { + "type": "boolean" + } + } + }, + "ok": true + }, + { + "note": "field-level private", + "slug": "T", + "schema": { + "properties": { + "ssn": { + "private": true + } + } + }, + "ok": false + }, + { + "note": "field-level private, nested in a definition", + "slug": "T", + "schema": { + "definitions": { + "a": { + "properties": { + "b": { + "properties": { + "c": { + "private": true + } + } + } + } + } + } + }, + "ok": false + }, + { + "note": "field-level private under a definition called \"enum\"", + "slug": "T", + "schema": { + "definitions": { + "enum": { + "properties": { + "s": { + "private": true + } + } + } + } + }, + "ok": false + }, + { + "note": "private on items, not a property", + "slug": "T", + "schema": { + "items": { + "private": true + } + }, + "ok": true + }, + { + "note": "private on a definition, not a property", + "slug": "T", + "schema": { + "definitions": { + "a": { + "private": true + } + } + }, + "ok": true + }, + { + "note": "private inside default, which is data", + "slug": "T", + "schema": { + "properties": { + "a": { + "default": { + "properties": { + "b": { + "private": true + } + } + } + } + } + }, + "ok": true + }, + { + "note": "pattern of 256", + "slug": "T", + "schema": { + "pattern": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "ok": true + }, + { + "note": "pattern of 257", + "slug": "T", + "schema": { + "pattern": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "ok": false + }, + { + "note": "pattern of 129 astral characters (258 UTF-16 code units)", + "slug": "T", + "schema": { + "pattern": "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀" + }, + "ok": false + }, + { + "note": "patternProperties key of 257", + "slug": "T", + "schema": { + "patternProperties": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": {} + } + }, + "ok": false + }, + { + "note": "pattern of 257 under a property called \"type\"", + "slug": "T", + "schema": { + "properties": { + "type": { + "type": "string", + "pattern": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + }, + "ok": false + }, + { + "note": "pattern of 257 inside enum, which is data", + "slug": "T", + "schema": { + "enum": [ + { + "pattern": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }, + "ok": true + }, + { + "note": "pattern of 257 inside const, which is data", + "slug": "T", + "schema": { + "const": { + "pattern": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "ok": true + }, + { + "note": "pattern of 257 inside default, which is data", + "slug": "T", + "schema": { + "default": { + "pattern": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "ok": true + }, + { + "note": "pattern of 257 inside examples, which is data", + "slug": "T", + "schema": { + "examples": [ + { + "pattern": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }, + "ok": true + }, + { + "note": "slug with a slash", + "slug": "a/b", + "schema": {}, + "ok": false + }, + { + "note": "slug starting with \".\"", + "slug": ".t", + "schema": {}, + "ok": false + }, + { + "note": "empty slug", + "slug": "", + "schema": {}, + "ok": false + }, + { + "note": "slug over 128 bytes", + "slug": "ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "schema": {}, + "ok": false + }, + { + "note": "not an object", + "slug": "T", + "schema": true, + "ok": false + }, + { + "note": "$schema draft-07", + "slug": "T", + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "ok": true + }, + { + "note": "$schema draft-07 without \"#\"", + "slug": "T", + "schema": { + "$schema": "http://json-schema.org/draft-07/schema" + }, + "ok": true + }, + { + "note": "$schema 2020-12", + "slug": "T", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + "ok": false + }, + { + "note": "$schema draft-04", + "slug": "T", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#" + }, + "ok": false + }, + { + "note": "invalid against the meta-schema: type", + "slug": "T", + "schema": { + "type": "text" + }, + "ok": false + }, + { + "note": "invalid against the meta-schema: required", + "slug": "T", + "schema": { + "required": "a" + }, + "ok": false + }, + { + "note": "a pattern that needs the u flag", + "slug": "T", + "schema": { + "pattern": "^\\p{L}+$" + }, + "ok": true + }, + { + "note": "an invalid pattern", + "slug": "T", + "schema": { + "pattern": "(" + }, + "ok": false + }, + { + "note": "a pattern invalid only with the u flag", + "slug": "T", + "schema": { + "pattern": "\\a" + }, + "ok": false + }, + { + "note": "a patternProperties key invalid only with the u flag", + "slug": "T", + "schema": { + "patternProperties": { + "\\a": {} + } + }, + "ok": false + }, + { + "note": "$ref to a definition", + "slug": "T", + "schema": { + "definitions": { + "a": { + "type": "string" + } + }, + "properties": { + "x": { + "$ref": "#/definitions/a" + } + } + }, + "ok": true + }, + { + "note": "$ref against the base URI", + "slug": "T", + "schema": { + "definitions": { + "a": { + "type": "string" + } + }, + "properties": { + "x": { + "$ref": "https://schema.underlay.invalid/#/definitions/a" + } + } + }, + "ok": true + }, + { + "note": "$ref to an $id", + "slug": "T", + "schema": { + "definitions": { + "a": { + "$id": "a.json", + "type": "string" + } + }, + "properties": { + "x": { + "$ref": "a.json" + } + } + }, + "ok": true + }, + { + "note": "$ref to the draft-07 meta-schema", + "slug": "T", + "schema": { + "$ref": "http://json-schema.org/draft-07/schema#" + }, + "ok": true + }, + { + "note": "unresolvable $ref", + "slug": "T", + "schema": { + "$ref": "#/definitions/missing" + }, + "ok": false + }, + { + "note": "unresolvable $ref to another document", + "slug": "T", + "schema": { + "properties": { + "x": { + "$ref": "other.json" + } + } + }, + "ok": false + } + ], + "recordHashes": [ + { + "id": "r1", + "type": "Author", + "data": { + "name": "Ada", + "year": 1815 + }, + "hash": "adefbd10aa438f0c6ed1627817f391ac6cc0441737ee09b4ebcc30fbd8386c63", + "canonical": "{\"id\":\"r1\",\"type\":\"Author\",\"data\":{\"name\":\"Ada\",\"year\":1815}}" + }, + { + "id": "r2", + "type": "Pub", + "data": { + "title": "On Computable Numbers", + "refs": [ + { + "$file": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }, + "hash": "0f96d6e03006818b0e48479a6e4317598c9e570e28b68cbff452bfb622d8d919", + "canonical": "{\"id\":\"r2\",\"type\":\"Pub\",\"data\":{\"refs\":[{\"$file\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}],\"title\":\"On Computable Numbers\"}}" + }, + { + "id": "9", + "type": "T", + "data": { + "9": "y", + "10": "x", + "z": [1.5, null, true] + }, + "hash": "ff47fdde5857af087fbb016abdc6b0983b78634e6eaab49b184cde1694e15c6d", + "canonical": "{\"id\":\"9\",\"type\":\"T\",\"data\":{\"10\":\"x\",\"9\":\"y\",\"z\":[1.5,null,true]}}" + }, + { + "id": "😀", + "type": "Émoji", + "data": "plain string data", + "hash": "3bbf6c464350187596dddc952fba294126ae3a62b962b078d7ba3a723f1f6582", + "canonical": "{\"id\":\"😀\",\"type\":\"Émoji\",\"data\":\"plain string data\"}" + } + ], + "schemaHashes": [ + { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "canonical": "{\"properties\":{\"name\":{\"type\":\"string\"}},\"type\":\"object\"}", + "hash": "2b7196d853bac7cea83330be9c2073848dedc10746eaf403bb5f73687531baf2" + }, + { + "schema": { + "type": "object", + "properties": { + "1": { + "type": "string" + }, + "10": { + "type": "integer" + } + }, + "private": true + }, + "canonical": "{\"private\":true,\"properties\":{\"1\":{\"type\":\"string\"},\"10\":{\"type\":\"integer\"}},\"type\":\"object\"}", + "hash": "2e61ca37bf92bd289753852ae33a88ec3df6e6606b761d4b0199cfd30a850ded" + } + ], + "boundary": [ + { + "key": "", + "u": "e3b0c44298fc1c14", + "trailingZeros": 2 + }, + { + "key": "a", + "u": "ca978112ca1bbdca", + "trailingZeros": 1 + }, + { + "key": "r0", + "u": "dd191696e15e2ee2", + "trailingZeros": 1 + }, + { + "key": "k12345", + "u": "054b40ca4964a150", + "trailingZeros": 4 + }, + { + "key": "😀", + "u": "f0443a342c5ef547", + "trailingZeros": 0 + }, + { + "key": "é", + "u": "bf12767b0f2a56b2", + "trailingZeros": 1 + }, + { + "key": "b525", + "u": "9de5e591b68ce800", + "trailingZeros": 11 + }, + { + "key": "b54026", + "u": "e36ea7df953a0000", + "trailingZeros": 17 + } + ], + "keyOrder": ["", "Z", "a", "aa", "b", "é", "", "€", "é", "", "￿", "😀"], + "nodes": { + "leaf": { + "keys": ["a", "b", "c"], + "recipe": "entries: for each key, hashRecord(key, \"T\", {\"k\": key}) → [key, hash, UTF-8 length of the canonical record]; sorted by key", + "hash": "7453c994a2387a24e5074f003b2b23660f9c310c0da3142c61981976817bcafa", + "json": "{\"e\":[[\"a\",\"6d3f26485cae4e156436aba786fad1ba0a2f7777525ed0835ca983d3dcca34c9\",38],[\"b\",\"3676805073b7e99c07673ec1c85a9da7383d1dfe7f6723c4c0b18b4459973421\",38],[\"c\",\"e4d4edd2615ca27ec9a5d5dd9f7328e10f325aa9836d17955a236c44073828ee\",38]],\"t\":\"leaf\"}" + }, + "interior": { + "recipe": "entries: for each key, hashRecord(key, \"T\", {\"k\": key}) → [key, hash, UTF-8 length of the canonical record]; sorted by key; keys \"r0\"..\"r2999\"; the root node", + "hash": "07af5e7b6a4395a189fcad73dcd10f0d57381838e60dd6bd5dd4ef701cff3618", + "json": "{\"e\":[[\"r2936\",\"a79185f218e237eb4199fbcede4cfcf83a1ff219b6da1270b9a2936eb3757372\",2154,98598],[\"r633\",\"e52ef8ca8081b67f556066e7a8716aef26d6cec5a653cb680b54a4d739346941\",441,19446],[\"r95\",\"9b54ff0cf254dd6c04c156b344be82e4134be503cf6d6328d63509705e2617a8\",351,15368],[\"r999\",\"54c1584f0968be01c571de2fdd3133a4c1227d17e3302b1b84f5286d9e424901\",54,2368]],\"l\":1,\"t\":\"node\"}" + } + }, + "trees": [ + { + "name": "empty", + "recipe": "entries: for each key, hashRecord(key, \"T\", {\"k\": key}) → [key, hash, UTF-8 length of the canonical record]; sorted by key; no keys", + "entries": 0, + "root": null, + "count": 0, + "bytes": 0, + "height": 0, + "nodesPerLevel": [] + }, + { + "name": "one", + "recipe": "entries: for each key, hashRecord(key, \"T\", {\"k\": key}) → [key, hash, UTF-8 length of the canonical record]; sorted by key; keys [\"only\"]", + "entries": 1, + "root": "691bf271695bfcfa5bdf79182bbe47d51aef8bf68278b0cd3a2c1f006094fc34", + "count": 1, + "bytes": 44, + "height": 1, + "nodesPerLevel": [1] + }, + { + "name": "small", + "recipe": "entries: for each key, hashRecord(key, \"T\", {\"k\": key}) → [key, hash, UTF-8 length of the canonical record]; sorted by key; keys \"r0\"..\"r999\"", + "entries": 1000, + "root": "ea81aa2788dfedb958068212effe511ea75d4ce094a2ce66e9b7cd70f77a93ed", + "count": 1000, + "bytes": 43780, + "height": 2, + "nodesPerLevel": [3, 1], + "leafSizes": [595, 351, 54] + }, + { + "name": "medium", + "recipe": "entries: for each key, hashRecord(key, \"T\", {\"k\": key}) → [key, hash, UTF-8 length of the canonical record]; sorted by key; keys \"r0\"..\"r99999\"", + "entries": 100000, + "root": "0e8532a8b014b2c0ec4895d582f7469390e38ed011305dbef315b501a34dc701", + "count": 100000, + "bytes": 4777780, + "height": 3, + "nodesPerLevel": [122, 4, 1] + }, + { + "name": "unicode", + "recipe": "entries: for each key, hashRecord(key, \"T\", {\"k\": key}) → [key, hash, UTF-8 length of the canonical record]; sorted by key; keys \"é\", \"e\\u0301\", \"😀\", \"\\uffff\" for i in 0..2499", + "entries": 10000, + "root": "c167944c45a676e0355920757ebdf4c6308a3554f6cb8a36d3e5833636ad5ec4", + "count": 10000, + "bytes": 491120, + "height": 2, + "nodesPerLevel": [11, 1], + "leafSizes": [735, 555, 707, 275, 2250, 609, 217, 1649, 622, 1619, 762] + }, + { + "name": "forced-leaf-splits", + "recipe": "entries: for each key, hashRecord(key, \"T\", {\"k\": key}) → [key, hash, UTF-8 length of the canonical record]; sorted by key; keys \"f\" for i in 0..19999 whose boundary hash has fewer than 10 trailing zero bits (no natural leaf boundary at all)", + "entries": 19981, + "root": "368f46bae82edcb837de3e6e2fa612703b9a2cc3ad52ff9f826e6b126464afe9", + "count": 19981, + "bytes": 936900, + "height": 2, + "nodesPerLevel": [3, 1], + "leafSizes": [8192, 8192, 3597] + } + ], + "fileTree": { + "recipe": "entries: key = sha256Hex(\"file\"), size = i × 1000 + 1, for i in 0..2999; sorted by key", + "root": "8398cadc93b9896cea5bf7f08ccfdaf4e2420c29bf71d23fdaeadfb657307e39", + "count": 3000, + "bytes": 4498503000 + }, + "roots": [ + { + "name": "public-only", + "root": { + "underlay": 2, + "metadata": { + "9": "y", + "10": "x", + "name": "Vector collection", + "readme": "# Hi\n", + "tags": ["a", "b"] + }, + "public": { + "types": { + "Author": { + "schema": "2b7196d853bac7cea83330be9c2073848dedc10746eaf403bb5f73687531baf2", + "root": "ea81aa2788dfedb958068212effe511ea75d4ce094a2ce66e9b7cd70f77a93ed", + "count": 1000, + "bytes": 43780 + }, + "Empty": { + "schema": "2b7196d853bac7cea83330be9c2073848dedc10746eaf403bb5f73687531baf2", + "root": null, + "count": 0, + "bytes": 0 + } + }, + "files": { + "root": "8398cadc93b9896cea5bf7f08ccfdaf4e2420c29bf71d23fdaeadfb657307e39", + "count": 3000, + "bytes": 4498503000 + } + }, + "private": null + }, + "canonical": "{\"metadata\":{\"10\":\"x\",\"9\":\"y\",\"name\":\"Vector collection\",\"readme\":\"# Hi\\n\",\"tags\":[\"a\",\"b\"]},\"private\":null,\"public\":{\"files\":{\"bytes\":4498503000,\"count\":3000,\"root\":\"8398cadc93b9896cea5bf7f08ccfdaf4e2420c29bf71d23fdaeadfb657307e39\"},\"types\":{\"Author\":{\"bytes\":43780,\"count\":1000,\"root\":\"ea81aa2788dfedb958068212effe511ea75d4ce094a2ce66e9b7cd70f77a93ed\",\"schema\":\"2b7196d853bac7cea83330be9c2073848dedc10746eaf403bb5f73687531baf2\"},\"Empty\":{\"bytes\":0,\"count\":0,\"root\":null,\"schema\":\"2b7196d853bac7cea83330be9c2073848dedc10746eaf403bb5f73687531baf2\"}}},\"underlay\":2}", + "versionHash": "ulv2:7d96a42b7841767d577510d2d25591e40a3ff67ae2bbdb16e15a2d12e0c60ed1" + }, + { + "name": "with-private", + "privateSet": { + "types": { + "Secret": { + "schema": "2e61ca37bf92bd289753852ae33a88ec3df6e6606b761d4b0199cfd30a850ded", + "root": "7453c994a2387a24e5074f003b2b23660f9c310c0da3142c61981976817bcafa", + "count": 3, + "bytes": 114 + } + }, + "files": { + "root": null, + "count": 0, + "bytes": 0 + }, + "salt": "9bd47d892258c4edb74ef4824f8fc23bb9e09267623afce8bc6e6e098257d84c" + }, + "privateCanonical": "{\"files\":{\"bytes\":0,\"count\":0,\"root\":null},\"salt\":\"9bd47d892258c4edb74ef4824f8fc23bb9e09267623afce8bc6e6e098257d84c\",\"types\":{\"Secret\":{\"bytes\":114,\"count\":3,\"root\":\"7453c994a2387a24e5074f003b2b23660f9c310c0da3142c61981976817bcafa\",\"schema\":\"2e61ca37bf92bd289753852ae33a88ec3df6e6606b761d4b0199cfd30a850ded\"}}}", + "commitment": "77c3bd2fc048eae2b77d74d765f1e5699b4fc27662668dc84165d6cf597ea811", + "root": { + "underlay": 2, + "metadata": { + "9": "y", + "10": "x", + "name": "Vector collection", + "readme": "# Hi\n", + "tags": ["a", "b"] + }, + "public": { + "types": { + "Author": { + "schema": "2b7196d853bac7cea83330be9c2073848dedc10746eaf403bb5f73687531baf2", + "root": "ea81aa2788dfedb958068212effe511ea75d4ce094a2ce66e9b7cd70f77a93ed", + "count": 1000, + "bytes": 43780 + }, + "Empty": { + "schema": "2b7196d853bac7cea83330be9c2073848dedc10746eaf403bb5f73687531baf2", + "root": null, + "count": 0, + "bytes": 0 + } + }, + "files": { + "root": "8398cadc93b9896cea5bf7f08ccfdaf4e2420c29bf71d23fdaeadfb657307e39", + "count": 3000, + "bytes": 4498503000 + } + }, + "private": "77c3bd2fc048eae2b77d74d765f1e5699b4fc27662668dc84165d6cf597ea811" + }, + "canonical": "{\"metadata\":{\"10\":\"x\",\"9\":\"y\",\"name\":\"Vector collection\",\"readme\":\"# Hi\\n\",\"tags\":[\"a\",\"b\"]},\"private\":\"77c3bd2fc048eae2b77d74d765f1e5699b4fc27662668dc84165d6cf597ea811\",\"public\":{\"files\":{\"bytes\":4498503000,\"count\":3000,\"root\":\"8398cadc93b9896cea5bf7f08ccfdaf4e2420c29bf71d23fdaeadfb657307e39\"},\"types\":{\"Author\":{\"bytes\":43780,\"count\":1000,\"root\":\"ea81aa2788dfedb958068212effe511ea75d4ce094a2ce66e9b7cd70f77a93ed\",\"schema\":\"2b7196d853bac7cea83330be9c2073848dedc10746eaf403bb5f73687531baf2\"},\"Empty\":{\"bytes\":0,\"count\":0,\"root\":null,\"schema\":\"2b7196d853bac7cea83330be9c2073848dedc10746eaf403bb5f73687531baf2\"}}},\"underlay\":2}", + "versionHash": "ulv2:c9998bb6ea5063e913226f6b612841dd02b87babf0d7826dce7635d02185f53c" + } + ], + "fileRefs": [ + { + "data": { + "f": { + "$file": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "refs": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + }, + { + "data": { + "list": [ + { + "$file": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "nested": { + "$file": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "extra": { + "$file": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + } + } + ] + }, + "refs": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + }, + { + "data": { + "f": { + "$file": "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "g": { + "$file": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "h": { + "$file": 1, + "inner": { + "$file": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + } + }, + "refs": ["ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"] + }, + { + "data": [ + { + "$file": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "$file": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "refs": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + } + ], + "logEntry": { + "privateKeySeed": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA", + "publicKey": { + "id": "65b60673d6ed884b", + "alg": "Ed25519", + "publicKey": "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ" + }, + "unsigned": { + "collectionId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b", + "seq": 2, + "semver": "v1.1.0", + "versionHash": "ulv2:7d96a42b7841767d577510d2d25591e40a3ff67ae2bbdb16e15a2d12e0c60ed1", + "baseSemver": "v1.0.0", + "message": "Second version", + "appId": null, + "actorId": null, + "createdAt": "2026-10-03T12:00:00.000Z", + "prev": "a8e1776584a85cc42b07c3852ba3991dcc68651c69ee859907e12ffe5bf0467a" + }, + "signedBytes": "{\"actorId\":null,\"appId\":null,\"baseSemver\":\"v1.0.0\",\"collectionId\":\"0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b\",\"createdAt\":\"2026-10-03T12:00:00.000Z\",\"keyId\":\"65b60673d6ed884b\",\"message\":\"Second version\",\"prev\":\"a8e1776584a85cc42b07c3852ba3991dcc68651c69ee859907e12ffe5bf0467a\",\"semver\":\"v1.1.0\",\"seq\":2,\"versionHash\":\"ulv2:7d96a42b7841767d577510d2d25591e40a3ff67ae2bbdb16e15a2d12e0c60ed1\"}", + "entry": { + "collectionId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b", + "seq": 2, + "semver": "v1.1.0", + "versionHash": "ulv2:7d96a42b7841767d577510d2d25591e40a3ff67ae2bbdb16e15a2d12e0c60ed1", + "baseSemver": "v1.0.0", + "message": "Second version", + "appId": null, + "actorId": null, + "createdAt": "2026-10-03T12:00:00.000Z", + "prev": "a8e1776584a85cc42b07c3852ba3991dcc68651c69ee859907e12ffe5bf0467a", + "keyId": "65b60673d6ed884b", + "sig": "jMYs3Y7sZS_Cio90TjPtBwKue12TvSW0YMjYbHmLKZuVTHlBtdyiNDupdHjo8u-s-X6kDcOVb5O_n2PWPCynBg" + }, + "canonical": "{\"actorId\":null,\"appId\":null,\"baseSemver\":\"v1.0.0\",\"collectionId\":\"0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b\",\"createdAt\":\"2026-10-03T12:00:00.000Z\",\"keyId\":\"65b60673d6ed884b\",\"message\":\"Second version\",\"prev\":\"a8e1776584a85cc42b07c3852ba3991dcc68651c69ee859907e12ffe5bf0467a\",\"semver\":\"v1.1.0\",\"seq\":2,\"sig\":\"jMYs3Y7sZS_Cio90TjPtBwKue12TvSW0YMjYbHmLKZuVTHlBtdyiNDupdHjo8u-s-X6kDcOVb5O_n2PWPCynBg\",\"versionHash\":\"ulv2:7d96a42b7841767d577510d2d25591e40a3ff67ae2bbdb16e15a2d12e0c60ed1\"}", + "entryHash": "a4f4efbbb99240becb9cf7f4f5d560243ecdb919819972c7e1100320fcdcad7f", + "head": "{\"entryHash\":\"a4f4efbbb99240becb9cf7f4f5d560243ecdb919819972c7e1100320fcdcad7f\",\"seq\":2,\"versionHash\":\"ulv2:7d96a42b7841767d577510d2d25591e40a3ff67ae2bbdb16e15a2d12e0c60ed1\"}" + } +} diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 0000000..227e751 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "target": "ES2024", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src", "test", "scripts"], + // The workerd bench imports a generated, gitignored data.json; it runs under wrangler. + "exclude": ["scripts/workerd-bench"] +} diff --git a/packages/protocol/vitest.config.ts b/packages/protocol/vitest.config.ts new file mode 100644 index 0000000..3e42797 --- /dev/null +++ b/packages/protocol/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}) diff --git a/packages/server/drizzle.config.ts b/packages/server/drizzle.config.ts new file mode 100644 index 0000000..8bc4c9a --- /dev/null +++ b/packages/server/drizzle.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'drizzle-kit' + +// One migration set for both runtimes: libsql applies it with drizzle's +// migrator, D1 with `wrangler d1 migrations apply` (migrations_dir = drizzle/). +export default defineConfig({ + dialect: 'sqlite', + schema: './src/db/schema.ts', + out: './drizzle', +}) diff --git a/packages/server/drizzle/0000_init.sql b/packages/server/drizzle/0000_init.sql new file mode 100644 index 0000000..86f221e --- /dev/null +++ b/packages/server/drizzle/0000_init.sql @@ -0,0 +1,463 @@ +CREATE TABLE `account` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, + `scope` text, + `password` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `account_user_id_idx` ON `account` (`user_id`);--> statement-breakpoint +CREATE TABLE `apikey` ( + `id` text PRIMARY KEY NOT NULL, + `config_id` text DEFAULT 'default' NOT NULL, + `name` text, + `start` text, + `reference_id` text NOT NULL, + `prefix` text, + `key` text NOT NULL, + `refill_interval` integer, + `refill_amount` integer, + `last_refill_at` integer, + `enabled` integer DEFAULT true, + `rate_limit_enabled` integer DEFAULT true, + `rate_limit_time_window` integer DEFAULT 86400000, + `rate_limit_max` integer DEFAULT 10, + `request_count` integer DEFAULT 0, + `remaining` integer, + `last_request` integer, + `expires_at` integer, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `updated_at` integer NOT NULL, + `permissions` text, + `metadata` text +); +--> statement-breakpoint +CREATE INDEX `apikey_key_idx` ON `apikey` (`key`);--> statement-breakpoint +CREATE INDEX `apikey_reference_id_idx` ON `apikey` (`reference_id`);--> statement-breakpoint +CREATE TABLE `ark_collections` ( + `collection_id` text PRIMARY KEY NOT NULL, + `ark_id` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `custom_url` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ark_collections_ark_id_unique` ON `ark_collections` (`ark_id`);--> statement-breakpoint +CREATE TABLE `ark_record_types` ( + `collection_id` text NOT NULL, + `record_type` text NOT NULL, + `redirect_url_field` text NOT NULL, + PRIMARY KEY(`collection_id`, `record_type`), + FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `ark_shoulders` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `shoulder` text NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ark_shoulders_shoulder_unique` ON `ark_shoulders` (`shoulder`);--> statement-breakpoint +CREATE TABLE `collection_webhooks` ( + `id` text PRIMARY KEY NOT NULL, + `collection_id` text NOT NULL, + `url` text NOT NULL, + `bump_filter` text NOT NULL, + `secret` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `created_by` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `updated_at` integer NOT NULL, + `last_delivery_at` integer, + FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `collection_webhooks_collection_idx` ON `collection_webhooks` (`collection_id`);--> statement-breakpoint +CREATE TABLE `collections` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `slug` text NOT NULL, + `name` text NOT NULL, + `public` integer DEFAULT false NOT NULL, + `head_version_id` text, + `private_salt` text NOT NULL, + `public_files_root` text, + `summary` text, + `ref_events` integer DEFAULT 0 NOT NULL, + `ref_bytes` integer DEFAULT 0 NOT NULL, + `deleted_at` integer, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `collections_org_slug_uq` ON `collections` (`organization_id`,`slug`);--> statement-breakpoint +CREATE INDEX `collections_public_updated_idx` ON `collections` (`public`,`updated_at`);--> statement-breakpoint +CREATE TABLE `denylist` ( + `hash` text PRIMARY KEY NOT NULL, + `kind` text NOT NULL, + `reason` text NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `file_uploads` ( + `id` text PRIMARY KEY NOT NULL, + `collection_id` text NOT NULL, + `session_id` text, + `hash` text NOT NULL, + `size` integer NOT NULL, + `mime_type` text NOT NULL, + `storage_key` text NOT NULL, + `multipart_upload_id` text, + `status` text DEFAULT 'pending' NOT NULL, + `error` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `file_uploads_hash_idx` ON `file_uploads` (`hash`);--> statement-breakpoint +CREATE TABLE `files` ( + `hash` text PRIMARY KEY NOT NULL, + `size` integer NOT NULL, + `mime_type` text NOT NULL, + `storage_key` text NOT NULL, + `verified_at` integer, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `forks` ( + `child_collection_id` text PRIMARY KEY NOT NULL, + `parent_collection_id` text NOT NULL, + `parent_seq` integer NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + FOREIGN KEY (`child_collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `instance_settings` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `invitation` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `email` text NOT NULL, + `role` text, + `status` text DEFAULT 'pending' NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `inviter_id` text NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`inviter_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `invitation_organization_id_idx` ON `invitation` (`organization_id`);--> statement-breakpoint +CREATE TABLE `jobs` ( + `id` text PRIMARY KEY NOT NULL, + `type` text NOT NULL, + `payload` text NOT NULL, + `status` text DEFAULT 'queued' NOT NULL, + `attempts` integer DEFAULT 0 NOT NULL, + `run_at` integer NOT NULL, + `locked_until` integer, + `error` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `jobs_ready_idx` ON `jobs` (`status`,`run_at`);--> statement-breakpoint +CREATE TABLE `legacy_hashes` ( + `legacy_hash` text PRIMARY KEY NOT NULL, + `kind` text NOT NULL, + `hash` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `member` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `user_id` text NOT NULL, + `role` text DEFAULT 'member' NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `member_organization_id_idx` ON `member` (`organization_id`);--> statement-breakpoint +CREATE INDEX `member_user_id_idx` ON `member` (`user_id`);--> statement-breakpoint +CREATE TABLE `organization` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text NOT NULL, + `logo` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `metadata` text, + `bio` text, + `website` text, + `avatar_url` text, + `ark_naan` text, + `kf_org_id` text, + `is_default` integer DEFAULT false +); +--> statement-breakpoint +CREATE UNIQUE INDEX `organization_slug_unique` ON `organization` (`slug`);--> statement-breakpoint +CREATE TABLE `page_comments` ( + `id` text PRIMARY KEY NOT NULL, + `page` text NOT NULL, + `anchor` text NOT NULL, + `quote` text, + `quote_context` text, + `parent_id` text, + `user_id` text NOT NULL, + `body` text NOT NULL, + `approved_at` integer, + `approved_by` text, + `status` text DEFAULT 'open' NOT NULL, + `resolution_note` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `edited_at` integer, + `deleted_at` integer +); +--> statement-breakpoint +CREATE INDEX `page_comments_page_idx` ON `page_comments` (`page`);--> statement-breakpoint +CREATE TABLE `placements` ( + `id` text PRIMARY KEY NOT NULL, + `collection_id` text, + `organization_id` text, + `location_id` text NOT NULL, + `role` text NOT NULL, + `sets` text NOT NULL, + `state` text DEFAULT 'active' NOT NULL, + `synced_seq` integer DEFAULT 0 NOT NULL, + `last_error` text, + `updated_at` integer NOT NULL, + FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`location_id`) REFERENCES `storage_locations`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `placements_one_primary_uq` ON `placements` (`collection_id`) WHERE "placements"."role" = 'primary' AND "placements"."collection_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `placements_target_location_uq` ON `placements` (`collection_id`,`organization_id`,`location_id`);--> statement-breakpoint +CREATE INDEX `placements_location_idx` ON `placements` (`location_id`);--> statement-breakpoint +CREATE TABLE `push_runs` ( + `session_id` text NOT NULL, + `seq` integer NOT NULL, + `kind` text NOT NULL, + `object_key` text NOT NULL, + `count` integer NOT NULL, + `first_key` text NOT NULL, + `last_key` text NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + PRIMARY KEY(`session_id`, `seq`), + FOREIGN KEY (`session_id`) REFERENCES `push_sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `push_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `collection_id` text NOT NULL, + `user_id` text NOT NULL, + `kind` text NOT NULL, + `base_version_id` text, + `base_semver` text, + `message` text, + `app_id` text, + `actor_id` text, + `strip_unknown_fields` integer DEFAULT false NOT NULL, + `manifest_expected` integer, + `manifest_received` integer DEFAULT 0 NOT NULL, + `manifest_needed` integer DEFAULT 0 NOT NULL, + `records_received` integer DEFAULT 0 NOT NULL, + `runs` integer DEFAULT 0 NOT NULL, + `status` text DEFAULT 'open' NOT NULL, + `result` text, + `error` text, + `finalize_started_at` integer, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `expires_at` integer NOT NULL, + FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `push_sessions_collection_idx` ON `push_sessions` (`collection_id`);--> statement-breakpoint +CREATE INDEX `push_sessions_expires_idx` ON `push_sessions` (`status`,`expires_at`);--> statement-breakpoint +CREATE TABLE `ref_compaction_parts` ( + `compaction_id` text NOT NULL, + `part` integer NOT NULL, + PRIMARY KEY(`compaction_id`, `part`) +); +--> statement-breakpoint +CREATE TABLE `ref_compactions` ( + `id` text PRIMARY KEY NOT NULL, + `tier` integer NOT NULL, + `input_runs` text NOT NULL, + `output_run` text NOT NULL, + `parts` integer NOT NULL, + `prefix_length` integer NOT NULL, + `status` text DEFAULT 'running' NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `ref_segments` ( + `id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `tier` integer NOT NULL, + `first_hash` text NOT NULL, + `last_hash` text NOT NULL, + `count` integer NOT NULL, + `bytes` integer NOT NULL, + `state` text DEFAULT 'live' NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `ref_segments_range_idx` ON `ref_segments` (`state`,`first_hash`,`last_hash`);--> statement-breakpoint +CREATE INDEX `ref_segments_run_idx` ON `ref_segments` (`run_id`);--> statement-breakpoint +CREATE INDEX `ref_segments_tier_idx` ON `ref_segments` (`tier`);--> statement-breakpoint +CREATE TABLE `schema_labels` ( + `schema_hash` text NOT NULL, + `label` text NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + PRIMARY KEY(`schema_hash`, `label`) +); +--> statement-breakpoint +CREATE INDEX `schema_labels_label_idx` ON `schema_labels` (`label`);--> statement-breakpoint +CREATE TABLE `schema_usage` ( + `schema_hash` text NOT NULL, + `collection_id` text NOT NULL, + `type_slug` text NOT NULL, + `set` text NOT NULL, + `from_seq` integer NOT NULL, + `to_seq` integer, + PRIMARY KEY(`collection_id`, `type_slug`, `set`, `from_seq`), + FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `schema_usage_hash_idx` ON `schema_usage` (`schema_hash`);--> statement-breakpoint +CREATE TABLE `schemas` ( + `hash` text PRIMARY KEY NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `session` ( + `id` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL, + `token` text NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `updated_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `user_id` text NOT NULL, + `active_organization_id` text, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint +CREATE INDEX `session_user_id_idx` ON `session` (`user_id`);--> statement-breakpoint +CREATE TABLE `storage_locations` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text, + `kind` text NOT NULL, + `name` text NOT NULL, + `endpoint` text, + `region` text, + `bucket` text, + `prefix` text DEFAULT '' NOT NULL, + `credentials` text, + `permissions` text NOT NULL, + `status` text DEFAULT 'unverified' NOT NULL, + `last_error` text, + `verified_at` integer, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `user` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `email_verified` integer DEFAULT false NOT NULL, + `image` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint +CREATE TABLE `verification` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint +CREATE TABLE `versions` ( + `id` text PRIMARY KEY NOT NULL, + `collection_id` text NOT NULL, + `seq` integer NOT NULL, + `semver` text NOT NULL, + `major` integer NOT NULL, + `minor` integer NOT NULL, + `patch` integer NOT NULL, + `hash` text NOT NULL, + `legacy_hash` text, + `legacy_public_hash` text, + `base_semver` text, + `message` text, + `pushed_by` text, + `app_id` text, + `actor_id` text, + `signature` text, + `record_count` integer NOT NULL, + `public_record_count` integer NOT NULL, + `file_count` integer NOT NULL, + `total_bytes` integer NOT NULL, + `public_file_count` integer DEFAULT 0 NOT NULL, + `public_total_bytes` integer DEFAULT 0 NOT NULL, + `type_counts` text NOT NULL, + `public_type_counts` text NOT NULL, + `has_private` integer DEFAULT false NOT NULL, + `public_refs_root` text, + `private_refs_root` text, + `refs_indexed` integer DEFAULT false NOT NULL, + `changes` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `versions_collection_seq_uq` ON `versions` (`collection_id`,`seq`);--> statement-breakpoint +CREATE UNIQUE INDEX `versions_collection_semver_uq` ON `versions` (`collection_id`,`semver`);--> statement-breakpoint +CREATE INDEX `versions_hash_idx` ON `versions` (`hash`);--> statement-breakpoint +CREATE INDEX `versions_legacy_hash_idx` ON `versions` (`legacy_hash`);--> statement-breakpoint +CREATE INDEX `versions_legacy_public_hash_idx` ON `versions` (`legacy_public_hash`);--> statement-breakpoint +CREATE TABLE `webhook_deliveries` ( + `id` text PRIMARY KEY NOT NULL, + `webhook_id` text NOT NULL, + `collection_id` text NOT NULL, + `version_id` text, + `semver` text, + `bump_type` text NOT NULL, + `event` text DEFAULT 'version.created' NOT NULL, + `payload` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `attempts` integer DEFAULT 0 NOT NULL, + `response_code` integer, + `error` text, + `duration_ms` integer, + `next_attempt_at` integer, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `delivered_at` integer, + FOREIGN KEY (`webhook_id`) REFERENCES `collection_webhooks`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `webhook_deliveries_webhook_idx` ON `webhook_deliveries` (`webhook_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `webhook_deliveries_pending_idx` ON `webhook_deliveries` (`status`,`next_attempt_at`); \ No newline at end of file diff --git a/packages/server/drizzle/0001_platform_location.sql b/packages/server/drizzle/0001_platform_location.sql new file mode 100644 index 0000000..180e883 --- /dev/null +++ b/packages/server/drizzle/0001_platform_location.sql @@ -0,0 +1,4 @@ +-- The deployment's own storage location. Its bucket and credentials come from +-- the deployment's bindings and secrets, not from this row. +INSERT INTO `storage_locations` (`id`, `organization_id`, `kind`, `name`, `prefix`, `permissions`, `status`) +VALUES ('platform', NULL, 'platform', 'Underlay', '', 'read_write', 'active'); diff --git a/packages/server/drizzle/0002_run_tiers.sql b/packages/server/drizzle/0002_run_tiers.sql new file mode 100644 index 0000000..fd085ad --- /dev/null +++ b/packages/server/drizzle/0002_run_tiers.sql @@ -0,0 +1,3 @@ +ALTER TABLE `push_runs` ADD `tier` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `push_runs` ADD `merging_into` integer;--> statement-breakpoint +CREATE INDEX `push_runs_tier_idx` ON `push_runs` (`session_id`,`tier`,`merging_into`); \ No newline at end of file diff --git a/packages/server/drizzle/0003_parallel_commit.sql b/packages/server/drizzle/0003_parallel_commit.sql new file mode 100644 index 0000000..8ca704e --- /dev/null +++ b/packages/server/drizzle/0003_parallel_commit.sql @@ -0,0 +1,20 @@ +CREATE TABLE `commit_units` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `plan_id` text NOT NULL, + `set` text NOT NULL, + `type` text NOT NULL, + `ord` integer NOT NULL, + `after` text, + `through` text, + `gap` integer DEFAULT false NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `slices_key` text, + `output_key` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `push_sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `commit_units_plan_idx` ON `commit_units` (`plan_id`,`set`,`type`,`ord`);--> statement-breakpoint +ALTER TABLE `push_sessions` ADD `commit_plan` text;--> statement-breakpoint +ALTER TABLE `push_sessions` ADD `assembly_lease` integer; \ No newline at end of file diff --git a/packages/server/drizzle/0004_mirror_progress.sql b/packages/server/drizzle/0004_mirror_progress.sql new file mode 100644 index 0000000..d235284 --- /dev/null +++ b/packages/server/drizzle/0004_mirror_progress.sql @@ -0,0 +1,2 @@ +ALTER TABLE `placements` ADD `cursor` text;--> statement-breakpoint +ALTER TABLE `placements` ADD `lease_until` integer; \ No newline at end of file diff --git a/packages/server/drizzle/0005_restores.sql b/packages/server/drizzle/0005_restores.sql new file mode 100644 index 0000000..8a24b47 --- /dev/null +++ b/packages/server/drizzle/0005_restores.sql @@ -0,0 +1,19 @@ +CREATE TABLE `restores` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `location_id` text NOT NULL, + `source_collection_id` text NOT NULL, + `collection_id` text NOT NULL, + `sets` text NOT NULL, + `trust_key_ids` text NOT NULL, + `status` text DEFAULT 'running' NOT NULL, + `restored_seq` integer DEFAULT 0 NOT NULL, + `last_entry_hash` text, + `last_version_hash` text, + `error` text, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`location_id`) REFERENCES `storage_locations`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/packages/server/drizzle/0006_fork_sets.sql b/packages/server/drizzle/0006_fork_sets.sql new file mode 100644 index 0000000..4b15b9d --- /dev/null +++ b/packages/server/drizzle/0006_fork_sets.sql @@ -0,0 +1 @@ +ALTER TABLE `forks` ADD `sets` text DEFAULT 'public' NOT NULL; \ No newline at end of file diff --git a/packages/server/drizzle/0007_placements_unique.sql b/packages/server/drizzle/0007_placements_unique.sql new file mode 100644 index 0000000..496200c --- /dev/null +++ b/packages/server/drizzle/0007_placements_unique.sql @@ -0,0 +1,6 @@ +DROP INDEX `placements_target_location_uq`;--> statement-breakpoint +-- The old index never fired (NULLs are distinct), so duplicates may exist: keep the oldest row. +DELETE FROM `placements` WHERE `collection_id` IS NOT NULL AND rowid NOT IN (SELECT MIN(rowid) FROM `placements` WHERE `collection_id` IS NOT NULL GROUP BY `collection_id`, `location_id`);--> statement-breakpoint +DELETE FROM `placements` WHERE `organization_id` IS NOT NULL AND rowid NOT IN (SELECT MIN(rowid) FROM `placements` WHERE `organization_id` IS NOT NULL GROUP BY `organization_id`, `location_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `placements_collection_location_uq` ON `placements` (`collection_id`,`location_id`) WHERE "placements"."collection_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `placements_org_location_uq` ON `placements` (`organization_id`,`location_id`) WHERE "placements"."organization_id" IS NOT NULL; diff --git a/packages/server/drizzle/0008_abuse_reports.sql b/packages/server/drizzle/0008_abuse_reports.sql new file mode 100644 index 0000000..e5cdac4 --- /dev/null +++ b/packages/server/drizzle/0008_abuse_reports.sql @@ -0,0 +1,14 @@ +CREATE TABLE `abuse_reports` ( + `id` text PRIMARY KEY NOT NULL, + `hash` text, + `url` text, + `reason` text NOT NULL, + `contact` text, + `reporter_id` text, + `status` text DEFAULT 'open' NOT NULL, + `resolved_by` text, + `resolved_at` integer, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `abuse_reports_status_idx` ON `abuse_reports` (`status`,`created_at`); \ No newline at end of file diff --git a/packages/server/drizzle/0009_push_caps.sql b/packages/server/drizzle/0009_push_caps.sql new file mode 100644 index 0000000..003ff71 --- /dev/null +++ b/packages/server/drizzle/0009_push_caps.sql @@ -0,0 +1,3 @@ +ALTER TABLE `commit_units` ADD `queued_at` integer;--> statement-breakpoint +CREATE INDEX `commit_units_status_idx` ON `commit_units` (`session_id`,`status`,`queued_at`);--> statement-breakpoint +CREATE INDEX `push_sessions_user_idx` ON `push_sessions` (`user_id`,`status`); \ No newline at end of file diff --git a/packages/server/drizzle/0010_collection_tombstones.sql b/packages/server/drizzle/0010_collection_tombstones.sql new file mode 100644 index 0000000..dc4441b --- /dev/null +++ b/packages/server/drizzle/0010_collection_tombstones.sql @@ -0,0 +1,11 @@ +CREATE TABLE `collection_tombstones` ( + `collection_id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `slug` text NOT NULL, + `ref_events` integer NOT NULL, + `ref_bytes` integer NOT NULL, + `versions` integer NOT NULL, + `total_bytes` integer NOT NULL, + `deleted_by` text, + `deleted_at` integer NOT NULL +); diff --git a/packages/server/drizzle/0011_reconcile.sql b/packages/server/drizzle/0011_reconcile.sql new file mode 100644 index 0000000..05f8190 --- /dev/null +++ b/packages/server/drizzle/0011_reconcile.sql @@ -0,0 +1,7 @@ +ALTER TABLE `collections` ADD `reconcile_started_at` integer;--> statement-breakpoint +ALTER TABLE `collections` ADD `reconciled_at` integer;--> statement-breakpoint +ALTER TABLE `collections` ADD `reconcile_report` text;--> statement-breakpoint +ALTER TABLE `versions` ADD `ref_events` integer;--> statement-breakpoint +ALTER TABLE `versions` ADD `ref_bytes` integer;--> statement-breakpoint +ALTER TABLE `versions` ADD `reconciled_at` integer;--> statement-breakpoint +ALTER TABLE `versions` ADD `reconcile_report` text; \ No newline at end of file diff --git a/packages/server/drizzle/0012_usage_rollups.sql b/packages/server/drizzle/0012_usage_rollups.sql new file mode 100644 index 0000000..8f24276 --- /dev/null +++ b/packages/server/drizzle/0012_usage_rollups.sql @@ -0,0 +1,10 @@ +CREATE TABLE `usage_rollups` ( + `day` text NOT NULL, + `account_id` text NOT NULL, + `collection_id` text NOT NULL, + `metric` text NOT NULL, + `amount` integer NOT NULL, + PRIMARY KEY(`day`, `account_id`, `collection_id`, `metric`) +); +--> statement-breakpoint +CREATE INDEX `usage_rollups_account_idx` ON `usage_rollups` (`account_id`,`day`); \ No newline at end of file diff --git a/packages/server/drizzle/0013_location_checked_at.sql b/packages/server/drizzle/0013_location_checked_at.sql new file mode 100644 index 0000000..3502794 --- /dev/null +++ b/packages/server/drizzle/0013_location_checked_at.sql @@ -0,0 +1 @@ +ALTER TABLE `storage_locations` ADD `checked_at` integer; \ No newline at end of file diff --git a/packages/server/drizzle/0014_drop_page_comments.sql b/packages/server/drizzle/0014_drop_page_comments.sql new file mode 100644 index 0000000..87513e4 --- /dev/null +++ b/packages/server/drizzle/0014_drop_page_comments.sql @@ -0,0 +1 @@ +DROP TABLE `page_comments`; \ No newline at end of file diff --git a/packages/server/drizzle/0015_storage_cleanup.sql b/packages/server/drizzle/0015_storage_cleanup.sql new file mode 100644 index 0000000..50cb672 --- /dev/null +++ b/packages/server/drizzle/0015_storage_cleanup.sql @@ -0,0 +1,33 @@ +CREATE TABLE `cleanup_runs` ( + `id` text PRIMARY KEY NOT NULL, + `step` text NOT NULL, + `status` text DEFAULT 'queued' NOT NULL, + `trigger` text NOT NULL, + `dry_run` integer DEFAULT false NOT NULL, + `requested_by` text, + `mark_run_id` text, + `state` text, + `seq` integer DEFAULT 0 NOT NULL, + `lease` integer, + `stats` text, + `error` text, + `started_at` integer, + `finished_at` integer, + `updated_at` integer, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `cleanup_runs_created_idx` ON `cleanup_runs` (`created_at`);--> statement-breakpoint +CREATE INDEX `cleanup_runs_step_idx` ON `cleanup_runs` (`step`,`status`);--> statement-breakpoint +CREATE TABLE `storage_fence` ( + `id` integer PRIMARY KEY NOT NULL, + `epoch` integer DEFAULT 0 NOT NULL, + `window_until` integer, + `window_run_id` text +); +--> statement-breakpoint +ALTER TABLE `push_sessions` ADD `cleaned_at` integer;--> statement-breakpoint +ALTER TABLE `versions` ADD `published_at` integer;--> statement-breakpoint +CREATE INDEX `versions_published_idx` ON `versions` (`published_at`);--> statement-breakpoint +-- The write fence's one row (cleanup/fence.ts). +INSERT INTO `storage_fence` (`id`, `epoch`) VALUES (1, 0); diff --git a/packages/server/drizzle/0016_drop_negotiate.sql b/packages/server/drizzle/0016_drop_negotiate.sql new file mode 100644 index 0000000..97451ab --- /dev/null +++ b/packages/server/drizzle/0016_drop_negotiate.sql @@ -0,0 +1,4 @@ +ALTER TABLE `push_sessions` DROP COLUMN `kind`;--> statement-breakpoint +ALTER TABLE `push_sessions` DROP COLUMN `manifest_expected`;--> statement-breakpoint +ALTER TABLE `push_sessions` DROP COLUMN `manifest_received`;--> statement-breakpoint +ALTER TABLE `push_sessions` DROP COLUMN `manifest_needed`; \ No newline at end of file diff --git a/packages/server/drizzle/0017_drop_v1_hashes.sql b/packages/server/drizzle/0017_drop_v1_hashes.sql new file mode 100644 index 0000000..d051d50 --- /dev/null +++ b/packages/server/drizzle/0017_drop_v1_hashes.sql @@ -0,0 +1,5 @@ +DROP TABLE `legacy_hashes`;--> statement-breakpoint +DROP INDEX `versions_legacy_hash_idx`;--> statement-breakpoint +DROP INDEX `versions_legacy_public_hash_idx`;--> statement-breakpoint +ALTER TABLE `versions` DROP COLUMN `legacy_hash`;--> statement-breakpoint +ALTER TABLE `versions` DROP COLUMN `legacy_public_hash`; \ No newline at end of file diff --git a/packages/server/drizzle/0018_drop_restore.sql b/packages/server/drizzle/0018_drop_restore.sql new file mode 100644 index 0000000..9d9d003 --- /dev/null +++ b/packages/server/drizzle/0018_drop_restore.sql @@ -0,0 +1,2 @@ +DROP TABLE `restores`;--> statement-breakpoint +ALTER TABLE `storage_locations` DROP COLUMN `permissions`; \ No newline at end of file diff --git a/packages/server/drizzle/0019_tombstone_trigger.sql b/packages/server/drizzle/0019_tombstone_trigger.sql new file mode 100644 index 0000000..f528fb9 --- /dev/null +++ b/packages/server/drizzle/0019_tombstone_trigger.sql @@ -0,0 +1,18 @@ +-- Every deleted collection gets a tombstone, however its row goes: the delete +-- route writes a full one first (this then does nothing), and a cascade from a +-- deleted org or account gets one here. Storage cleanup keeps a tombstoned +-- collection's objects through its grace period, and reference-log compaction +-- drops its events. +CREATE TRIGGER IF NOT EXISTS `collections_tombstone` BEFORE DELETE ON `collections` +BEGIN + INSERT OR IGNORE INTO `collection_tombstones` + (`collection_id`, `organization_id`, `slug`, `ref_events`, `ref_bytes`, `versions`, + `total_bytes`, `deleted_by`, `deleted_at`) + VALUES ( + OLD.`id`, OLD.`organization_id`, OLD.`slug`, OLD.`ref_events`, OLD.`ref_bytes`, + (SELECT count(*) FROM `versions` WHERE `collection_id` = OLD.`id`), + (SELECT coalesce(sum(`total_bytes`), 0) FROM `versions` WHERE `collection_id` = OLD.`id`), + NULL, + CAST(unixepoch('subsec') * 1000 AS INTEGER) + ); +END; diff --git a/packages/server/drizzle/0020_reconcile_state.sql b/packages/server/drizzle/0020_reconcile_state.sql new file mode 100644 index 0000000..e69750c --- /dev/null +++ b/packages/server/drizzle/0020_reconcile_state.sql @@ -0,0 +1,3 @@ +ALTER TABLE `collections` ADD `reconcile_state` text;--> statement-breakpoint +ALTER TABLE `collections` ADD `reconciled_seq` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `collections` ADD `reconciled_files_root` text; \ No newline at end of file diff --git a/packages/server/drizzle/0021_ref_index_units.sql b/packages/server/drizzle/0021_ref_index_units.sql new file mode 100644 index 0000000..47e1cd3 --- /dev/null +++ b/packages/server/drizzle/0021_ref_index_units.sql @@ -0,0 +1,7 @@ +CREATE TABLE `ref_index_units` ( + `version_id` text NOT NULL, + `unit` integer NOT NULL, + `events` integer NOT NULL, + `bytes` integer NOT NULL, + PRIMARY KEY(`version_id`, `unit`) +); diff --git a/packages/server/drizzle/0022_collection_history.sql b/packages/server/drizzle/0022_collection_history.sql new file mode 100644 index 0000000..f0288f9 --- /dev/null +++ b/packages/server/drizzle/0022_collection_history.sql @@ -0,0 +1,8 @@ +ALTER TABLE `collections` ADD `version_count` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `collections` ADD `history_bytes` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `collections` ADD `last_push_at` integer;--> statement-breakpoint +-- Backfill from the versions each collection already has. +UPDATE `collections` SET + `version_count` = (SELECT count(*) FROM `versions` v WHERE v.`collection_id` = `collections`.`id`), + `history_bytes` = (SELECT coalesce(sum(v.`total_bytes`), 0) FROM `versions` v WHERE v.`collection_id` = `collections`.`id`), + `last_push_at` = (SELECT max(v.`created_at`) FROM `versions` v WHERE v.`collection_id` = `collections`.`id`); diff --git a/packages/server/drizzle/0023_version_push_session.sql b/packages/server/drizzle/0023_version_push_session.sql new file mode 100644 index 0000000..b1d3399 --- /dev/null +++ b/packages/server/drizzle/0023_version_push_session.sql @@ -0,0 +1,2 @@ +ALTER TABLE `versions` ADD `push_session_id` text;--> statement-breakpoint +CREATE INDEX `versions_push_session_idx` ON `versions` (`push_session_id`); \ No newline at end of file diff --git a/src/db/migrations/meta/0016_snapshot.json b/packages/server/drizzle/meta/0000_snapshot.json similarity index 50% rename from src/db/migrations/meta/0016_snapshot.json rename to packages/server/drizzle/meta/0000_snapshot.json index 79921bc..7a2a2ab 100644 --- a/src/db/migrations/meta/0016_snapshot.json +++ b/packages/server/drizzle/meta/0000_snapshot.json @@ -1,109 +1,110 @@ { - "id": "696a6c76-20ed-435f-856a-34710097af1a", - "prevId": "adf91649-cf6a-416e-a88b-662bae9b576d", - "version": "7", - "dialect": "postgresql", + "version": "6", + "dialect": "sqlite", + "id": "77ee0bb4-7f78-4dae-8821-66450c4fbb6a", + "prevId": "00000000-0000-0000-0000-000000000000", "tables": { - "public.account": { + "account": { "name": "account", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "account_id": { "name": "account_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "access_token": { "name": "access_token", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "id_token": { "name": "id_token", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "access_token_expires_at": { "name": "access_token_expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "refresh_token_expires_at": { "name": "refresh_token_expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "scope": { "name": "scope", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "password": { "name": "password", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false } }, "indexes": { "account_user_id_idx": { "name": "account_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "columns": ["user_id"], + "isUnique": false } }, "foreignKeys": { @@ -119,87 +120,96 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.apikey": { + "apikey": { "name": "apikey", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "config_id": { "name": "config_id", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'default'" }, "name": { "name": "name", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "start": { "name": "start", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "reference_id": { "name": "reference_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "prefix": { "name": "prefix", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "key": { "name": "key", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "refill_interval": { "name": "refill_interval", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "refill_amount": { "name": "refill_amount", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "last_refill_at": { "name": "last_refill_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "enabled": { "name": "enabled", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": false, + "autoincrement": false, "default": true }, "rate_limit_enabled": { "name": "rate_limit_enabled", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": false, + "autoincrement": false, "default": true }, "rate_limit_time_window": { @@ -207,6 +217,7 @@ "type": "integer", "primaryKey": false, "notNull": false, + "autoincrement": false, "default": 86400000 }, "rate_limit_max": { @@ -214,6 +225,7 @@ "type": "integer", "primaryKey": false, "notNull": false, + "autoincrement": false, "default": 10 }, "request_count": { @@ -221,145 +233,125 @@ "type": "integer", "primaryKey": false, "notNull": false, + "autoincrement": false, "default": 0 }, "remaining": { "name": "remaining", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "last_request": { "name": "last_request", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "expires_at": { "name": "expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false }, "permissions": { "name": "permissions", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "metadata": { "name": "metadata", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": { - "apikey_config_id_idx": { - "name": "apikey_config_id_idx", - "columns": [ - { - "expression": "config_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false }, "apikey_reference_id_idx": { "name": "apikey_reference_id_idx", - "columns": [ - { - "expression": "reference_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "apikey_key_idx": { - "name": "apikey_key_idx", - "columns": [ - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "columns": ["reference_id"], + "isUnique": false } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.ark_collections": { + "ark_collections": { "name": "ark_collections", - "schema": "", "columns": { "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "ark_id": { "name": "ark_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "enabled": { "name": "enabled", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": true }, "custom_url": { "name": "custom_url", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true } }, - "indexes": {}, "foreignKeys": { "ark_collections_collection_id_collections_id_fk": { "name": "ark_collections_collection_id_collections_id_fk", @@ -372,38 +364,32 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "ark_collections_ark_id_unique": { - "name": "ark_collections_ark_id_unique", - "nullsNotDistinct": false, - "columns": ["ark_id"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.ark_record_types": { + "ark_record_types": { "name": "ark_record_types", - "schema": "", "columns": { "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "record_type": { "name": "record_type", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "redirect_url_field": { "name": "redirect_url_field", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false } }, "indexes": {}, @@ -420,47 +406,53 @@ }, "compositePrimaryKeys": { "ark_record_types_collection_id_record_type_pk": { - "name": "ark_record_types_collection_id_record_type_pk", - "columns": ["collection_id", "record_type"] + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" } }, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.ark_shoulders": { + "ark_shoulders": { "name": "ark_shoulders", - "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "organization_id": { "name": "organization_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "shoulder": { "name": "shoulder", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true } }, - "indexes": {}, "foreignKeys": { "ark_shoulders_organization_id_organization_id_fk": { "name": "ark_shoulders_organization_id_organization_id_fk", @@ -473,107 +465,90 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "ark_shoulders_organization_id_unique": { - "name": "ark_shoulders_organization_id_unique", - "nullsNotDistinct": false, - "columns": ["organization_id"] - }, - "ark_shoulders_shoulder_unique": { - "name": "ark_shoulders_shoulder_unique", - "nullsNotDistinct": false, - "columns": ["shoulder"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.collection_webhooks": { + "collection_webhooks": { "name": "collection_webhooks", - "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "url": { "name": "url", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "bump_filter": { "name": "bump_filter", - "type": "text[]", + "type": "text", "primaryKey": false, "notNull": true, - "default": "'{major,minor,patch}'::text[]" + "autoincrement": false }, "secret": { "name": "secret", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "enabled": { "name": "enabled", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": true }, "created_by": { "name": "created_by", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false }, "last_delivery_at": { "name": "last_delivery_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": { - "collection_webhooks_collection_id_idx": { - "name": "collection_webhooks_collection_id_idx", - "columns": [ - { - "expression": "collection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false } }, "foreignKeys": { @@ -585,95 +560,128 @@ "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "collection_webhooks_created_by_user_id_fk": { - "name": "collection_webhooks_created_by_user_id_fk", - "tableFrom": "collection_webhooks", - "tableTo": "user", - "columnsFrom": ["created_by"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.collections": { + "collections": { "name": "collections", - "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "organization_id": { "name": "organization_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "slug": { "name": "slug", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "name": { "name": "name", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "public": { "name": "public", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": false }, - "forked_from": { - "name": "forked_from", - "type": "uuid", + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false } }, "indexes": { - "collections_organization_id_idx": { - "name": "collections_organization_id_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false } }, "foreignKeys": { @@ -685,520 +693,981 @@ "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "collections_forked_from_collections_id_fk": { - "name": "collections_forked_from_collections_id_fk", - "tableFrom": "collections", - "tableTo": "collections", - "columnsFrom": ["forked_from"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "collections_organization_id_slug_unique": { - "name": "collections_organization_id_slug_unique", - "nullsNotDistinct": false, - "columns": ["organization_id", "slug"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.files": { - "name": "files", - "schema": "", + "denylist": { + "name": "denylist", "columns": { "hash": { "name": "hash", "type": "text", "primaryKey": true, - "notNull": true - }, - "size": { - "name": "size", - "type": "bigint", - "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "mime_type": { - "name": "mime_type", + "kind": { + "name": "kind", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "storage_key": { - "name": "storage_key", + "reason": { + "name": "reason", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.instance_settings": { - "name": "instance_settings", - "schema": "", + "file_uploads": { + "name": "file_uploads", "columns": { - "key": { - "name": "key", + "id": { + "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, - "value": { - "name": "value", - "type": "jsonb", + "collection_id": { + "name": "collection_id", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", + "session_id": { + "name": "session_id", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.invitation": { - "name": "invitation", - "schema": "", - "columns": { - "id": { - "name": "id", + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", "type": "text", - "primaryKey": true, - "notNull": true + "primaryKey": false, + "notNull": true, + "autoincrement": false }, - "organization_id": { - "name": "organization_id", + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "email": { - "name": "email", + "storage_key": { + "name": "storage_key", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "role": { - "name": "role", + "multipart_upload_id": { + "name": "multipart_upload_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "status": { "name": "status", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'pending'" }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", + "error": { + "name": "error", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" - }, - "inviter_id": { - "name": "inviter_id", - "type": "text", - "primaryKey": false, - "notNull": true + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": { - "invitation_organization_id_idx": { - "name": "invitation_organization_id_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "invitation_email_idx": { - "name": "invitation_email_idx", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "invitation_organization_id_organization_id_fk": { - "name": "invitation_organization_id_organization_id_fk", - "tableFrom": "invitation", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "invitation_inviter_id_user_id_fk": { - "name": "invitation_inviter_id_user_id_fk", - "tableFrom": "invitation", - "tableTo": "user", - "columnsFrom": ["inviter_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.member": { - "name": "member", - "schema": "", + "files": { + "name": "files", "columns": { - "id": { - "name": "id", + "hash": { + "name": "hash", "type": "text", "primaryKey": true, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "role": { - "name": "role", + "body": { + "name": "body", "type": "text", "primaryKey": false, "notNull": true, - "default": "'member'" + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" - } - }, - "indexes": { - "member_organization_id_idx": { - "name": "member_organization_id_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, - "member_user_id_idx": { - "name": "member_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false } }, - "foreignKeys": { - "member_organization_id_organization_id_fk": { - "name": "member_organization_id_organization_id_fk", - "tableFrom": "member", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "member_user_id_user_id_fk": { - "name": "member_user_id_user_id_fk", - "tableFrom": "member", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.metadata_jobs": { - "name": "metadata_jobs", - "schema": "", + "placements": { + "name": "placements", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, - "user_id": { - "name": "user_id", + "organization_id": { + "name": "organization_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, - "status": { - "name": "status", + "location_id": { + "name": "location_id", "type": "text", "primaryKey": false, "notNull": true, - "default": "'running'" + "autoincrement": false }, - "base_semver": { - "name": "base_semver", + "role": { + "name": "role", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "metadata": { - "name": "metadata", - "type": "jsonb", + "sets": { + "name": "sets", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "result": { - "name": "result", - "type": "jsonb", + "state": { + "name": "state", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": "'active'" }, - "error": { - "name": "error", - "type": "jsonb", + "synced_seq": { + "name": "synced_seq", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": 0 }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", + "last_error": { + "name": "last_error", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": false, + "autoincrement": false }, - "finished_at": { - "name": "finished_at", - "type": "timestamp with time zone", + "updated_at": { + "name": "updated_at", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false } }, "indexes": { - "metadata_jobs_collection_status_idx": { - "name": "metadata_jobs_collection_status_idx", - "columns": [ - { - "expression": "collection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_target_location_uq": { + "name": "placements_target_location_uq", + "columns": ["collection_id", "organization_id", "location_id"], + "isUnique": true + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false } }, "foreignKeys": { - "metadata_jobs_collection_id_collections_id_fk": { - "name": "metadata_jobs_collection_id_collections_id_fk", - "tableFrom": "metadata_jobs", + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", "tableTo": "collections", "columnsFrom": ["collection_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, - "metadata_jobs_user_id_user_id_fk": { - "name": "metadata_jobs_user_id_user_id_fk", - "tableFrom": "metadata_jobs", - "tableTo": "user", - "columnsFrom": ["user_id"], + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], "columnsTo": ["id"], - "onDelete": "set null", + "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.negotiate_session_manifest": { - "name": "negotiate_session_manifest", - "schema": "", + "push_runs": { + "name": "push_runs", "columns": { "session_id": { "name": "session_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "record_id": { - "name": "record_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "type": { - "name": "type", - "type": "text", + "seq": { + "name": "seq", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "hash": { - "name": "hash", + "kind": { + "name": "kind", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "private": { - "name": "private", - "type": "boolean", + "object_key": { + "name": "object_key", + "type": "text", "primaryKey": false, "notNull": true, - "default": false + "autoincrement": false }, - "needed": { - "name": "needed", - "type": "boolean", + "count": { + "name": "count", + "type": "integer", "primaryKey": false, "notNull": true, - "default": true + "autoincrement": false }, - "submitted": { - "name": "submitted", - "type": "boolean", + "first_key": { + "name": "first_key", + "type": "text", "primaryKey": false, "notNull": true, - "default": false + "autoincrement": false }, - "final_hash": { - "name": "final_hash", + "last_key": { + "name": "last_key", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "public_hash": { - "name": "public_hash", - "type": "text", + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "nsm_session_needed_idx": { - "name": "nsm_session_needed_idx", - "columns": [ - { - "expression": "session_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "needed", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, + "indexes": {}, "foreignKeys": { - "negotiate_session_manifest_session_id_negotiate_sessions_id_fk": { - "name": "negotiate_session_manifest_session_id_negotiate_sessions_id_fk", - "tableFrom": "negotiate_session_manifest", - "tableTo": "negotiate_sessions", + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", @@ -1206,107 +1675,101 @@ } }, "compositePrimaryKeys": { - "negotiate_session_manifest_session_id_hash_pk": { - "name": "negotiate_session_manifest_session_id_hash_pk", - "columns": ["session_id", "hash"] + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" } }, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.negotiate_sessions": { - "name": "negotiate_sessions", - "schema": "", + "push_sessions": { + "name": "push_sessions", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "base_semver": { - "name": "base_semver", + "kind": { + "name": "kind", "type": "text", "primaryKey": false, - "notNull": false - }, - "schemas": { - "name": "schemas", - "type": "jsonb", - "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "file_hashes": { - "name": "file_hashes", - "type": "jsonb", + "base_version_id": { + "name": "base_version_id", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" + "notNull": false, + "autoincrement": false }, - "needed_files": { - "name": "needed_files", - "type": "jsonb", + "base_semver": { + "name": "base_semver", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" + "notNull": false, + "autoincrement": false }, "message": { "name": "message", "type": "text", "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "app_id": { "name": "app_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "actor_id": { "name": "actor_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "strip_unknown_fields": { "name": "strip_unknown_fields", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": false }, "manifest_expected": { "name": "manifest_expected", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "manifest_received": { "name": "manifest_received", "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": 0 }, "manifest_needed": { @@ -1314,6 +1777,23 @@ "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, "default": 0 }, "status": { @@ -1321,587 +1801,480 @@ "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'open'" }, "result": { "name": "result", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "error": { "name": "error", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "finalize_started_at": { "name": "finalize_started_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "expires_at": { "name": "expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false } }, - "indexes": {}, "foreignKeys": { - "negotiate_sessions_collection_id_collections_id_fk": { - "name": "negotiate_sessions_collection_id_collections_id_fk", - "tableFrom": "negotiate_sessions", + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", "tableTo": "collections", "columnsFrom": ["collection_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "negotiate_sessions_user_id_user_id_fk": { - "name": "negotiate_sessions_user_id_user_id_fk", - "tableFrom": "negotiate_sessions", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.organization": { - "name": "organization", - "schema": "", + "ref_compaction_parts": { + "name": "ref_compaction_parts", "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "logo": { - "name": "logo", + "compaction_id": { + "name": "compaction_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", + "part": { + "name": "part", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false }, - "metadata": { - "name": "metadata", - "type": "text", + "tier": { + "name": "tier", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "bio": { - "name": "bio", + "input_runs": { + "name": "input_runs", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "website": { - "name": "website", + "output_run": { + "name": "output_run", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "avatar_url": { - "name": "avatar_url", - "type": "text", + "parts": { + "name": "parts", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "ark_naan": { - "name": "ark_naan", - "type": "text", + "prefix_length": { + "name": "prefix_length", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "kf_org_id": { - "name": "kf_org_id", + "status": { + "name": "status", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": "'running'" }, - "is_default": { - "name": "is_default", - "type": "boolean", + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, - "notNull": false, - "default": false - } - }, - "indexes": { - "organization_slug_uidx": { - "name": "organization_slug_uidx", - "columns": [ - { - "expression": "slug", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, + "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "organization_slug_unique": { - "name": "organization_slug_unique", - "nullsNotDistinct": false, - "columns": ["slug"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.page_comments": { - "name": "page_comments", - "schema": "", + "ref_segments": { + "name": "ref_segments", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" - }, - "page": { - "name": "page", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "anchor": { - "name": "anchor", - "type": "text", - "primaryKey": false, - "notNull": true + "autoincrement": false }, - "quote": { - "name": "quote", + "run_id": { + "name": "run_id", "type": "text", "primaryKey": false, - "notNull": false - }, - "quote_context": { - "name": "quote_context", - "type": "jsonb", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "parent_id": { - "name": "parent_id", - "type": "uuid", + "tier": { + "name": "tier", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "user_id": { - "name": "user_id", + "first_hash": { + "name": "first_hash", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "body": { - "name": "body", + "last_hash": { + "name": "last_hash", "type": "text", "primaryKey": false, - "notNull": true - }, - "approved_at": { - "name": "approved_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "approved_by": { - "name": "approved_by", - "type": "text", + "count": { + "name": "count", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "status": { - "name": "status", - "type": "text", + "bytes": { + "name": "bytes", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "'open'" + "autoincrement": false }, - "resolution_note": { - "name": "resolution_note", + "state": { + "name": "state", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": "'live'" }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" - }, - "edited_at": { - "name": "edited_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": { - "page_comments_page_anchor_idx": { - "name": "page_comments_page_anchor_idx", - "columns": [ - { - "expression": "page", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "anchor", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "page_comments_user_id_idx": { - "name": "page_comments_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "page_comments_user_id_user_id_fk": { - "name": "page_comments_user_id_user_id_fk", - "tableFrom": "page_comments", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.record_objects": { - "name": "record_objects", - "schema": "", + "schema_labels": { + "name": "schema_labels", "columns": { - "hash": { - "name": "hash", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "record_id": { - "name": "record_id", + "schema_hash": { + "name": "schema_hash", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "type": { - "name": "type", + "label": { + "name": "label", "type": "text", "primaryKey": false, - "notNull": true - }, - "data": { - "name": "data", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "integer", - "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": { - "record_objects_record_id_idx": { - "name": "record_objects_record_id_idx", - "columns": [ - { - "expression": "record_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false } }, "foreignKeys": {}, - "compositePrimaryKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.schema_labels": { - "name": "schema_labels", - "schema": "", + "schema_usage": { + "name": "schema_usage", "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, - "schema_id": { - "name": "schema_id", - "type": "uuid", + "collection_id": { + "name": "collection_id", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "label": { - "name": "label", + "type_slug": { + "name": "type_slug", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false } }, "indexes": { - "schema_labels_label_idx": { - "name": "schema_labels_label_idx", - "columns": [ - { - "expression": "label", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false } }, "foreignKeys": { - "schema_labels_schema_id_schemas_id_fk": { - "name": "schema_labels_schema_id_schemas_id_fk", - "tableFrom": "schema_labels", - "tableTo": "schemas", - "columnsFrom": ["schema_id"], + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "schema_labels_schema_id_label_unique": { - "name": "schema_labels_schema_id_label_unique", - "nullsNotDistinct": false, - "columns": ["schema_id", "label"] + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.schemas": { + "schemas": { "name": "schemas", - "schema": "", "columns": { - "id": { - "name": "id", - "type": "uuid", + "hash": { + "name": "hash", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "schema_hash": { - "name": "schema_hash", - "type": "text", - "primaryKey": false, - "notNull": true + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "schemas_schema_hash_unique": { - "name": "schemas_schema_hash_unique", - "nullsNotDistinct": false, - "columns": ["schema_hash"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.session": { + "session": { "name": "session", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "expires_at": { "name": "expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "token": { "name": "token", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false }, "ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "active_organization_id": { "name": "active_organization_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, "session_user_id_idx": { "name": "session_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "columns": ["user_id"], + "isUnique": false } }, "foreignKeys": { @@ -1916,726 +2289,500 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.sync_runs": { - "name": "sync_runs", - "schema": "", + "storage_locations": { + "name": "storage_locations", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, - "trigger": { - "name": "trigger", + "organization_id": { + "name": "organization_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, - "status": { - "name": "status", + "kind": { + "name": "kind", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", + "name": { + "name": "name", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "finished_at": { - "name": "finished_at", - "type": "timestamp with time zone", + "endpoint": { + "name": "endpoint", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, - "collections_synced": { - "name": "collections_synced", - "type": "integer", + "region": { + "name": "region", + "type": "text", "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": false, + "autoincrement": false }, - "collections_created": { - "name": "collections_created", - "type": "integer", + "bucket": { + "name": "bucket", + "type": "text", "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": false, + "autoincrement": false }, - "collections_failed": { - "name": "collections_failed", - "type": "integer", + "prefix": { + "name": "prefix", + "type": "text", "primaryKey": false, "notNull": true, - "default": 0 + "autoincrement": false, + "default": "''" }, - "versions_pulled": { - "name": "versions_pulled", - "type": "integer", + "credentials": { + "name": "credentials", + "type": "text", "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": false, + "autoincrement": false }, - "files_downloaded": { - "name": "files_downloaded", - "type": "integer", + "permissions": { + "name": "permissions", + "type": "text", "primaryKey": false, "notNull": true, - "default": 0 + "autoincrement": false }, - "files_skipped": { - "name": "files_skipped", - "type": "integer", + "status": { + "name": "status", + "type": "text", "primaryKey": false, "notNull": true, - "default": 0 + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, - "errors": { - "name": "errors", - "type": "jsonb", + "verified_at": { + "name": "verified_at", + "type": "integer", "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" + "notNull": false, + "autoincrement": false }, - "logs": { - "name": "logs", - "type": "jsonb", + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "'[]'::jsonb" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": {}, - "foreignKeys": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.user": { + "user": { "name": "user", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "name": { "name": "name", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "email": { "name": "email", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "email_verified": { "name": "email_verified", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": false }, "image": { "name": "image", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false } }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { + "indexes": { "user_email_unique": { "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] + "columns": ["email"], + "isUnique": true } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.verification": { + "verification": { "name": "verification", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "identifier": { "name": "identifier", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "value": { "name": "value", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "expires_at": { "name": "expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false } }, "indexes": { "verification_identifier_idx": { "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "columns": ["identifier"], + "isUnique": false } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.version_files": { - "name": "version_files", - "schema": "", - "columns": { - "version_id": { - "name": "version_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "file_hash": { - "name": "file_hash", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "version_files_file_hash_idx": { - "name": "version_files_file_hash_idx", - "columns": [ - { - "expression": "file_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "version_files_version_id_versions_id_fk": { - "name": "version_files_version_id_versions_id_fk", - "tableFrom": "version_files", - "tableTo": "versions", - "columnsFrom": ["version_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "version_files_file_hash_files_hash_fk": { - "name": "version_files_file_hash_files_hash_fk", - "tableFrom": "version_files", - "tableTo": "files", - "columnsFrom": ["file_hash"], - "columnsTo": ["hash"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "version_files_version_id_file_hash_pk": { - "name": "version_files_version_id_file_hash_pk", - "columns": ["version_id", "file_hash"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.version_records": { - "name": "version_records", - "schema": "", - "columns": { - "version_id": { - "name": "version_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "record_hash": { - "name": "record_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public_record_hash": { - "name": "public_record_hash", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "private": { - "name": "private", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "record_id": { - "name": "record_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "version_records_record_hash_idx": { - "name": "version_records_record_hash_idx", - "columns": [ - { - "expression": "record_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "version_records_public_record_hash_idx": { - "name": "version_records_public_record_hash_idx", - "columns": [ - { - "expression": "public_record_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "public_record_hash IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "version_records_version_record_idx": { - "name": "version_records_version_record_idx", - "columns": [ - { - "expression": "version_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "record_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "version_records_version_type_record_idx": { - "name": "version_records_version_type_record_idx", - "columns": [ - { - "expression": "version_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "record_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "version_records_version_id_versions_id_fk": { - "name": "version_records_version_id_versions_id_fk", - "tableFrom": "version_records", - "tableTo": "versions", - "columnsFrom": ["version_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "version_records_record_hash_record_objects_hash_fk": { - "name": "version_records_record_hash_record_objects_hash_fk", - "tableFrom": "version_records", - "tableTo": "record_objects", - "columnsFrom": ["record_hash"], - "columnsTo": ["hash"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "version_records_version_id_record_hash_pk": { - "name": "version_records_version_id_record_hash_pk", - "columns": ["version_id", "record_hash"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.version_schemas": { - "name": "version_schemas", - "schema": "", - "columns": { - "version_id": { - "name": "version_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema_id": { - "name": "schema_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "version_schemas_schema_id_idx": { - "name": "version_schemas_schema_id_idx", - "columns": [ - { - "expression": "schema_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "version_schemas_version_id_versions_id_fk": { - "name": "version_schemas_version_id_versions_id_fk", - "tableFrom": "version_schemas", - "tableTo": "versions", - "columnsFrom": ["version_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "version_schemas_schema_id_schemas_id_fk": { - "name": "version_schemas_schema_id_schemas_id_fk", - "tableFrom": "version_schemas", - "tableTo": "schemas", - "columnsFrom": ["schema_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "version_schemas_version_id_slug_pk": { - "name": "version_schemas_version_id_slug_pk", - "columns": ["version_id", "slug"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.versions": { + "versions": { "name": "versions", - "schema": "", "columns": { "id": { "name": "id", - "type": "bigserial", + "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "semver": { "name": "semver", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "major": { "name": "major", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "minor": { "name": "minor", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "patch": { "name": "patch", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "hash": { "name": "hash", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, - "public_hash": { - "name": "public_hash", + "legacy_public_hash": { + "name": "legacy_public_hash", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "base_semver": { "name": "base_semver", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "message": { "name": "message", "type": "text", "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "pushed_by": { "name": "pushed_by", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "app_id": { "name": "app_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "actor_id": { "name": "actor_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "signature": { "name": "signature", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "record_count": { "name": "record_count", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false }, "file_count": { "name": "file_count", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 }, "type_counts": { "name": "type_counts", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "total_bytes": { - "name": "total_bytes", - "type": "bigint", + "public_type_counts": { + "name": "public_type_counts", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "records_from_version_id": { - "name": "records_from_version_id", - "type": "bigint", + "has_private": { + "name": "has_private", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": false }, - "status": { - "name": "status", + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", "type": "text", "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, "notNull": true, - "default": "'ready'" + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": { - "versions_ordering_idx": { - "name": "versions_ordering_idx", - "columns": [ - { - "expression": "collection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "major", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "minor", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "patch", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "versions_records_from_version_idx": { - "name": "versions_records_from_version_idx", - "columns": [ - { - "expression": "records_from_version_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"versions\".\"records_from_version_id\" is not null", - "concurrently": false, - "method": "btree", - "with": {} + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false } }, "foreignKeys": { @@ -2647,102 +2794,78 @@ "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "versions_pushed_by_user_id_fk": { - "name": "versions_pushed_by_user_id_fk", - "tableFrom": "versions", - "tableTo": "user", - "columnsFrom": ["pushed_by"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "versions_records_from_version_id_versions_id_fk": { - "name": "versions_records_from_version_id_versions_id_fk", - "tableFrom": "versions", - "tableTo": "versions", - "columnsFrom": ["records_from_version_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "versions_collection_id_semver_unique": { - "name": "versions_collection_id_semver_unique", - "nullsNotDistinct": false, - "columns": ["collection_id", "semver"] - }, - "versions_collection_id_hash_public_hash_unique": { - "name": "versions_collection_id_hash_public_hash_unique", - "nullsNotDistinct": false, - "columns": ["collection_id", "hash", "public_hash"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.webhook_deliveries": { + "webhook_deliveries": { "name": "webhook_deliveries", - "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "webhook_id": { "name": "webhook_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "version_id": { "name": "version_id", - "type": "bigint", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "semver": { "name": "semver", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "bump_type": { "name": "bump_type", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "event": { "name": "event", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'version.created'" }, "payload": { "name": "payload", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "status": { "name": "status", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'pending'" }, "attempts": { @@ -2750,112 +2873,63 @@ "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": 0 }, "response_code": { "name": "response_code", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "error": { "name": "error", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "duration_ms": { "name": "duration_ms", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "next_attempt_at": { "name": "next_attempt_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "delivered_at": { "name": "delivered_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": { - "webhook_deliveries_webhook_id_idx": { - "name": "webhook_deliveries_webhook_id_idx", - "columns": [ - { - "expression": "webhook_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "webhook_deliveries_collection_id_idx": { - "name": "webhook_deliveries_collection_id_idx", - "columns": [ - { - "expression": "collection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "webhook_deliveries_created_at_idx": { - "name": "webhook_deliveries_created_at_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "webhook_deliveries_sweep_idx": { - "name": "webhook_deliveries_sweep_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "next_attempt_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false } }, "foreignKeys": { @@ -2867,42 +2941,21 @@ "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "webhook_deliveries_collection_id_collections_id_fk": { - "name": "webhook_deliveries_collection_id_collections_id_fk", - "tableFrom": "webhook_deliveries", - "tableTo": "collections", - "columnsFrom": ["collection_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "webhook_deliveries_version_id_versions_id_fk": { - "name": "webhook_deliveries_version_id_versions_id_fk", - "tableFrom": "webhook_deliveries", - "tableTo": "versions", - "columnsFrom": ["version_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} } }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, "views": {}, + "enums": {}, "_meta": { - "columns": {}, "schemas": {}, - "tables": {} + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} } } diff --git a/packages/server/drizzle/meta/0001_snapshot.json b/packages/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..14f5543 --- /dev/null +++ b/packages/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,2961 @@ +{ + "id": "088590c4-e66d-4011-891b-84b0aea7034c", + "prevId": "77ee0bb4-7f78-4dae-8821-66450c4fbb6a", + "version": "6", + "dialect": "sqlite", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "columnsFrom": ["child_collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "columnsFrom": ["inviter_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL", + "isUnique": true + }, + "placements_target_location_uq": { + "name": "placements_target_location_uq", + "columns": ["collection_id", "organization_id", "location_id"], + "isUnique": true + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "columnsFrom": ["location_id"], + "tableTo": "storage_locations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "columnsFrom": ["session_id"], + "tableTo": "push_sessions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "columnsFrom": ["webhook_id"], + "tableTo": "collection_webhooks", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/src/db/migrations/meta/0017_snapshot.json b/packages/server/drizzle/meta/0002_snapshot.json similarity index 50% rename from src/db/migrations/meta/0017_snapshot.json rename to packages/server/drizzle/meta/0002_snapshot.json index fb11cab..8a575c3 100644 --- a/src/db/migrations/meta/0017_snapshot.json +++ b/packages/server/drizzle/meta/0002_snapshot.json @@ -1,109 +1,110 @@ { - "id": "a1202021-6659-4248-a03f-1c59445da0fd", - "prevId": "696a6c76-20ed-435f-856a-34710097af1a", - "version": "7", - "dialect": "postgresql", + "version": "6", + "dialect": "sqlite", + "id": "6702f02b-7423-4f01-844b-b8b701e35c83", + "prevId": "088590c4-e66d-4011-891b-84b0aea7034c", "tables": { - "public.account": { + "account": { "name": "account", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "account_id": { "name": "account_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "access_token": { "name": "access_token", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "id_token": { "name": "id_token", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "access_token_expires_at": { "name": "access_token_expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "refresh_token_expires_at": { "name": "refresh_token_expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "scope": { "name": "scope", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "password": { "name": "password", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false } }, "indexes": { "account_user_id_idx": { "name": "account_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "columns": ["user_id"], + "isUnique": false } }, "foreignKeys": { @@ -119,87 +120,96 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.apikey": { + "apikey": { "name": "apikey", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "config_id": { "name": "config_id", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'default'" }, "name": { "name": "name", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "start": { "name": "start", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "reference_id": { "name": "reference_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "prefix": { "name": "prefix", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "key": { "name": "key", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "refill_interval": { "name": "refill_interval", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "refill_amount": { "name": "refill_amount", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "last_refill_at": { "name": "last_refill_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "enabled": { "name": "enabled", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": false, + "autoincrement": false, "default": true }, "rate_limit_enabled": { "name": "rate_limit_enabled", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": false, + "autoincrement": false, "default": true }, "rate_limit_time_window": { @@ -207,6 +217,7 @@ "type": "integer", "primaryKey": false, "notNull": false, + "autoincrement": false, "default": 86400000 }, "rate_limit_max": { @@ -214,6 +225,7 @@ "type": "integer", "primaryKey": false, "notNull": false, + "autoincrement": false, "default": 10 }, "request_count": { @@ -221,145 +233,125 @@ "type": "integer", "primaryKey": false, "notNull": false, + "autoincrement": false, "default": 0 }, "remaining": { "name": "remaining", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "last_request": { "name": "last_request", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "expires_at": { "name": "expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false }, "permissions": { "name": "permissions", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "metadata": { "name": "metadata", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": { - "apikey_config_id_idx": { - "name": "apikey_config_id_idx", - "columns": [ - { - "expression": "config_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false }, "apikey_reference_id_idx": { "name": "apikey_reference_id_idx", - "columns": [ - { - "expression": "reference_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "apikey_key_idx": { - "name": "apikey_key_idx", - "columns": [ - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "columns": ["reference_id"], + "isUnique": false } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.ark_collections": { + "ark_collections": { "name": "ark_collections", - "schema": "", "columns": { "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "ark_id": { "name": "ark_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "enabled": { "name": "enabled", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": true }, "custom_url": { "name": "custom_url", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true } }, - "indexes": {}, "foreignKeys": { "ark_collections_collection_id_collections_id_fk": { "name": "ark_collections_collection_id_collections_id_fk", @@ -372,38 +364,32 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "ark_collections_ark_id_unique": { - "name": "ark_collections_ark_id_unique", - "nullsNotDistinct": false, - "columns": ["ark_id"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.ark_record_types": { + "ark_record_types": { "name": "ark_record_types", - "schema": "", "columns": { "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "record_type": { "name": "record_type", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "redirect_url_field": { "name": "redirect_url_field", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false } }, "indexes": {}, @@ -420,47 +406,53 @@ }, "compositePrimaryKeys": { "ark_record_types_collection_id_record_type_pk": { - "name": "ark_record_types_collection_id_record_type_pk", - "columns": ["collection_id", "record_type"] + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" } }, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.ark_shoulders": { + "ark_shoulders": { "name": "ark_shoulders", - "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "organization_id": { "name": "organization_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "shoulder": { "name": "shoulder", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true } }, - "indexes": {}, "foreignKeys": { "ark_shoulders_organization_id_organization_id_fk": { "name": "ark_shoulders_organization_id_organization_id_fk", @@ -473,107 +465,90 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "ark_shoulders_organization_id_unique": { - "name": "ark_shoulders_organization_id_unique", - "nullsNotDistinct": false, - "columns": ["organization_id"] - }, - "ark_shoulders_shoulder_unique": { - "name": "ark_shoulders_shoulder_unique", - "nullsNotDistinct": false, - "columns": ["shoulder"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.collection_webhooks": { + "collection_webhooks": { "name": "collection_webhooks", - "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "url": { "name": "url", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "bump_filter": { "name": "bump_filter", - "type": "text[]", + "type": "text", "primaryKey": false, "notNull": true, - "default": "'{major,minor,patch}'::text[]" + "autoincrement": false }, "secret": { "name": "secret", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "enabled": { "name": "enabled", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": true }, "created_by": { "name": "created_by", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false }, "last_delivery_at": { "name": "last_delivery_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": { - "collection_webhooks_collection_id_idx": { - "name": "collection_webhooks_collection_id_idx", - "columns": [ - { - "expression": "collection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false } }, "foreignKeys": { @@ -585,95 +560,128 @@ "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "collection_webhooks_created_by_user_id_fk": { - "name": "collection_webhooks_created_by_user_id_fk", - "tableFrom": "collection_webhooks", - "tableTo": "user", - "columnsFrom": ["created_by"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.collections": { + "collections": { "name": "collections", - "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "organization_id": { "name": "organization_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "slug": { "name": "slug", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "name": { "name": "name", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "public": { "name": "public", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": false }, - "forked_from": { - "name": "forked_from", - "type": "uuid", + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false } }, "indexes": { - "collections_organization_id_idx": { - "name": "collections_organization_id_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false } }, "foreignKeys": { @@ -685,285 +693,536 @@ "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "collections_forked_from_collections_id_fk": { - "name": "collections_forked_from_collections_id_fk", - "tableFrom": "collections", - "tableTo": "collections", - "columnsFrom": ["forked_from"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "collections_organization_id_slug_unique": { - "name": "collections_organization_id_slug_unique", - "nullsNotDistinct": false, - "columns": ["organization_id", "slug"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.files": { - "name": "files", - "schema": "", + "denylist": { + "name": "denylist", "columns": { "hash": { "name": "hash", "type": "text", "primaryKey": true, - "notNull": true - }, - "size": { - "name": "size", - "type": "bigint", - "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "mime_type": { - "name": "mime_type", + "kind": { + "name": "kind", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "storage_key": { - "name": "storage_key", + "reason": { + "name": "reason", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.instance_settings": { - "name": "instance_settings", - "schema": "", + "file_uploads": { + "name": "file_uploads", "columns": { - "key": { - "name": "key", + "id": { + "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, - "value": { - "name": "value", - "type": "jsonb", + "collection_id": { + "name": "collection_id", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", + "session_id": { + "name": "session_id", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.invitation": { - "name": "invitation", - "schema": "", - "columns": { - "id": { - "name": "id", + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", "type": "text", - "primaryKey": true, - "notNull": true + "primaryKey": false, + "notNull": true, + "autoincrement": false }, - "organization_id": { - "name": "organization_id", + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "email": { - "name": "email", + "storage_key": { + "name": "storage_key", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "role": { - "name": "role", + "multipart_upload_id": { + "name": "multipart_upload_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "status": { "name": "status", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'pending'" }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", + "error": { + "name": "error", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" - }, - "inviter_id": { - "name": "inviter_id", - "type": "text", - "primaryKey": false, - "notNull": true + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": { - "invitation_organization_id_idx": { - "name": "invitation_organization_id_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "invitation_email_idx": { - "name": "invitation_email_idx", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "invitation_organization_id_organization_id_fk": { - "name": "invitation_organization_id_organization_id_fk", - "tableFrom": "invitation", - "tableTo": "organization", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "invitation_inviter_id_user_id_fk": { - "name": "invitation_inviter_id_user_id_fk", - "tableFrom": "invitation", - "tableTo": "user", - "columnsFrom": ["inviter_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.member": { - "name": "member", - "schema": "", + "files": { + "name": "files", "columns": { - "id": { - "name": "id", + "hash": { + "name": "hash", "type": "text", "primaryKey": true, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'member'" }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": { "member_organization_id_idx": { "name": "member_organization_id_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "columns": ["organization_id"], + "isUnique": false }, "member_user_id_idx": { "name": "member_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "columns": ["user_id"], + "isUnique": false } }, "foreignKeys": { @@ -988,217 +1247,448 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.metadata_jobs": { - "name": "metadata_jobs", - "schema": "", + "organization": { + "name": "organization", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, - "collection_id": { - "name": "collection_id", - "type": "uuid", + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, "status": { "name": "status", "type": "text", "primaryKey": false, "notNull": true, - "default": "'running'" + "autoincrement": false, + "default": "'open'" }, - "base_semver": { - "name": "base_semver", + "resolution_note": { + "name": "resolution_note", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, - "metadata": { - "name": "metadata", - "type": "jsonb", + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, - "result": { - "name": "result", - "type": "jsonb", + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, - "error": { - "name": "error", - "type": "jsonb", + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", + "updated_at": { + "name": "updated_at", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" - }, - "finished_at": { - "name": "finished_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "autoincrement": false } }, "indexes": { - "metadata_jobs_collection_status_idx": { - "name": "metadata_jobs_collection_status_idx", - "columns": [ - { - "expression": "collection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_target_location_uq": { + "name": "placements_target_location_uq", + "columns": ["collection_id", "organization_id", "location_id"], + "isUnique": true + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false } }, "foreignKeys": { - "metadata_jobs_collection_id_collections_id_fk": { - "name": "metadata_jobs_collection_id_collections_id_fk", - "tableFrom": "metadata_jobs", + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", "tableTo": "collections", "columnsFrom": ["collection_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, - "metadata_jobs_user_id_user_id_fk": { - "name": "metadata_jobs_user_id_user_id_fk", - "tableFrom": "metadata_jobs", - "tableTo": "user", - "columnsFrom": ["user_id"], + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], "columnsTo": ["id"], - "onDelete": "set null", + "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.negotiate_session_manifest": { - "name": "negotiate_session_manifest", - "schema": "", + "push_runs": { + "name": "push_runs", "columns": { "session_id": { "name": "session_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "record_id": { - "name": "record_id", - "type": "text", + "seq": { + "name": "seq", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "type": { - "name": "type", + "kind": { + "name": "kind", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "hash": { - "name": "hash", + "object_key": { + "name": "object_key", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "private": { - "name": "private", - "type": "boolean", + "count": { + "name": "count", + "type": "integer", "primaryKey": false, "notNull": true, - "default": false + "autoincrement": false }, - "needed": { - "name": "needed", - "type": "boolean", + "first_key": { + "name": "first_key", + "type": "text", "primaryKey": false, "notNull": true, - "default": true + "autoincrement": false }, - "submitted": { - "name": "submitted", - "type": "boolean", + "last_key": { + "name": "last_key", + "type": "text", "primaryKey": false, "notNull": true, - "default": false + "autoincrement": false }, - "final_hash": { - "name": "final_hash", - "type": "text", + "tier": { + "name": "tier", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": 0 }, - "public_hash": { - "name": "public_hash", - "type": "text", + "merging_into": { + "name": "merging_into", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": { - "nsm_session_needed_idx": { - "name": "nsm_session_needed_idx", - "columns": [ - { - "expression": "session_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "needed", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false } }, "foreignKeys": { - "negotiate_session_manifest_session_id_negotiate_sessions_id_fk": { - "name": "negotiate_session_manifest_session_id_negotiate_sessions_id_fk", - "tableFrom": "negotiate_session_manifest", - "tableTo": "negotiate_sessions", + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", @@ -1206,107 +1696,101 @@ } }, "compositePrimaryKeys": { - "negotiate_session_manifest_session_id_hash_pk": { - "name": "negotiate_session_manifest_session_id_hash_pk", - "columns": ["session_id", "hash"] + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" } }, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.negotiate_sessions": { - "name": "negotiate_sessions", - "schema": "", + "push_sessions": { + "name": "push_sessions", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "base_semver": { - "name": "base_semver", + "kind": { + "name": "kind", "type": "text", "primaryKey": false, - "notNull": false - }, - "schemas": { - "name": "schemas", - "type": "jsonb", - "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "file_hashes": { - "name": "file_hashes", - "type": "jsonb", + "base_version_id": { + "name": "base_version_id", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" + "notNull": false, + "autoincrement": false }, - "needed_files": { - "name": "needed_files", - "type": "jsonb", + "base_semver": { + "name": "base_semver", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" + "notNull": false, + "autoincrement": false }, "message": { "name": "message", "type": "text", "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "app_id": { "name": "app_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "actor_id": { "name": "actor_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "strip_unknown_fields": { "name": "strip_unknown_fields", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": false }, "manifest_expected": { "name": "manifest_expected", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "manifest_received": { "name": "manifest_received", "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": 0 }, "manifest_needed": { @@ -1314,6 +1798,23 @@ "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, "default": 0 }, "status": { @@ -1321,587 +1822,480 @@ "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'open'" }, "result": { "name": "result", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "error": { "name": "error", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "finalize_started_at": { "name": "finalize_started_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "expires_at": { "name": "expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false } }, - "indexes": {}, "foreignKeys": { - "negotiate_sessions_collection_id_collections_id_fk": { - "name": "negotiate_sessions_collection_id_collections_id_fk", - "tableFrom": "negotiate_sessions", + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", "tableTo": "collections", "columnsFrom": ["collection_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "negotiate_sessions_user_id_user_id_fk": { - "name": "negotiate_sessions_user_id_user_id_fk", - "tableFrom": "negotiate_sessions", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.organization": { - "name": "organization", - "schema": "", + "ref_compaction_parts": { + "name": "ref_compaction_parts", "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", + "compaction_id": { + "name": "compaction_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "slug": { - "name": "slug", - "type": "text", + "part": { + "name": "part", + "type": "integer", "primaryKey": false, - "notNull": true - }, - "logo": { - "name": "logo", + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, + "primaryKey": true, "notNull": true, - "default": "now()" + "autoincrement": false }, - "metadata": { - "name": "metadata", - "type": "text", + "tier": { + "name": "tier", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "bio": { - "name": "bio", + "input_runs": { + "name": "input_runs", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "website": { - "name": "website", + "output_run": { + "name": "output_run", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "avatar_url": { - "name": "avatar_url", - "type": "text", + "parts": { + "name": "parts", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "ark_naan": { - "name": "ark_naan", - "type": "text", + "prefix_length": { + "name": "prefix_length", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "kf_org_id": { - "name": "kf_org_id", + "status": { + "name": "status", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": "'running'" }, - "is_default": { - "name": "is_default", - "type": "boolean", + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, - "notNull": false, - "default": false - } - }, - "indexes": { - "organization_slug_uidx": { - "name": "organization_slug_uidx", - "columns": [ - { - "expression": "slug", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, + "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "organization_slug_unique": { - "name": "organization_slug_unique", - "nullsNotDistinct": false, - "columns": ["slug"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.page_comments": { - "name": "page_comments", - "schema": "", + "ref_segments": { + "name": "ref_segments", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" - }, - "page": { - "name": "page", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "anchor": { - "name": "anchor", - "type": "text", - "primaryKey": false, - "notNull": true + "autoincrement": false }, - "quote": { - "name": "quote", + "run_id": { + "name": "run_id", "type": "text", "primaryKey": false, - "notNull": false - }, - "quote_context": { - "name": "quote_context", - "type": "jsonb", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "parent_id": { - "name": "parent_id", - "type": "uuid", + "tier": { + "name": "tier", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "user_id": { - "name": "user_id", + "first_hash": { + "name": "first_hash", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "body": { - "name": "body", + "last_hash": { + "name": "last_hash", "type": "text", "primaryKey": false, - "notNull": true - }, - "approved_at": { - "name": "approved_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "approved_by": { - "name": "approved_by", - "type": "text", + "count": { + "name": "count", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "status": { - "name": "status", - "type": "text", + "bytes": { + "name": "bytes", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "'open'" + "autoincrement": false }, - "resolution_note": { - "name": "resolution_note", + "state": { + "name": "state", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": "'live'" }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" - }, - "edited_at": { - "name": "edited_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": { - "page_comments_page_anchor_idx": { - "name": "page_comments_page_anchor_idx", - "columns": [ - { - "expression": "page", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "anchor", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "page_comments_user_id_idx": { - "name": "page_comments_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "page_comments_user_id_user_id_fk": { - "name": "page_comments_user_id_user_id_fk", - "tableFrom": "page_comments", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.record_objects": { - "name": "record_objects", - "schema": "", + "schema_labels": { + "name": "schema_labels", "columns": { - "hash": { - "name": "hash", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "record_id": { - "name": "record_id", + "schema_hash": { + "name": "schema_hash", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "type": { - "name": "type", + "label": { + "name": "label", "type": "text", "primaryKey": false, - "notNull": true - }, - "data": { - "name": "data", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "integer", - "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": { - "record_objects_record_id_idx": { - "name": "record_objects_record_id_idx", - "columns": [ - { - "expression": "record_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false } }, "foreignKeys": {}, - "compositePrimaryKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.schema_labels": { - "name": "schema_labels", - "schema": "", + "schema_usage": { + "name": "schema_usage", "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, - "schema_id": { - "name": "schema_id", - "type": "uuid", + "type_slug": { + "name": "type_slug", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "label": { - "name": "label", + "set": { + "name": "set", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", + "from_seq": { + "name": "from_seq", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false } }, "indexes": { - "schema_labels_label_idx": { - "name": "schema_labels_label_idx", - "columns": [ - { - "expression": "label", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false } }, "foreignKeys": { - "schema_labels_schema_id_schemas_id_fk": { - "name": "schema_labels_schema_id_schemas_id_fk", - "tableFrom": "schema_labels", - "tableTo": "schemas", - "columnsFrom": ["schema_id"], + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "schema_labels_schema_id_label_unique": { - "name": "schema_labels_schema_id_label_unique", - "nullsNotDistinct": false, - "columns": ["schema_id", "label"] + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.schemas": { + "schemas": { "name": "schemas", - "schema": "", "columns": { - "id": { - "name": "id", - "type": "uuid", + "hash": { + "name": "hash", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "schema_hash": { - "name": "schema_hash", - "type": "text", - "primaryKey": false, - "notNull": true + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "schemas_schema_hash_unique": { - "name": "schemas_schema_hash_unique", - "nullsNotDistinct": false, - "columns": ["schema_hash"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.session": { + "session": { "name": "session", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "expires_at": { "name": "expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "token": { "name": "token", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false }, "ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "active_organization_id": { "name": "active_organization_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, "session_user_id_idx": { "name": "session_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "columns": ["user_id"], + "isUnique": false } }, "foreignKeys": { @@ -1916,840 +2310,500 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.sync_runs": { - "name": "sync_runs", - "schema": "", + "storage_locations": { + "name": "storage_locations", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, - "trigger": { - "name": "trigger", + "organization_id": { + "name": "organization_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, - "status": { - "name": "status", + "kind": { + "name": "kind", "type": "text", "primaryKey": false, - "notNull": true - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "finished_at": { - "name": "finished_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "collections_synced": { - "name": "collections_synced", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "collections_created": { - "name": "collections_created", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "collections_failed": { - "name": "collections_failed", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "versions_pulled": { - "name": "versions_pulled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "files_downloaded": { - "name": "files_downloaded", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "files_skipped": { - "name": "files_skipped", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "errors": { - "name": "errors", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "logs": { - "name": "logs", - "type": "jsonb", - "primaryKey": false, "notNull": true, - "default": "'[]'::jsonb" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true + "autoincrement": false }, "name": { "name": "name", "type": "text", "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true + "autoincrement": false }, - "value": { - "name": "value", + "endpoint": { + "name": "endpoint", "type": "text", "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.version_file_refs": { - "name": "version_file_refs", - "schema": "", - "columns": { - "version_id": { - "name": "version_id", - "type": "bigint", - "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, - "file_hash": { - "name": "file_hash", + "region": { + "name": "region", "type": "text", "primaryKey": false, - "notNull": true - }, - "prefixed": { - "name": "prefixed", - "type": "boolean", - "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, - "record_id": { - "name": "record_id", + "bucket": { + "name": "bucket", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, - "type": { - "name": "type", + "prefix": { + "name": "prefix", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false, + "default": "''" }, - "field": { - "name": "field", + "credentials": { + "name": "credentials", "type": "text", "primaryKey": false, - "notNull": true - }, - "nested": { - "name": "nested", - "type": "boolean", - "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, - "private": { - "name": "private", - "type": "boolean", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "version_file_refs_version_idx": { - "name": "version_file_refs_version_idx", - "columns": [ - { - "expression": "version_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "file_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "version_file_refs_file_hash_idx": { - "name": "version_file_refs_file_hash_idx", - "columns": [ - { - "expression": "file_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "version_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "version_file_refs_version_id_versions_id_fk": { - "name": "version_file_refs_version_id_versions_id_fk", - "tableFrom": "version_file_refs", - "tableTo": "versions", - "columnsFrom": ["version_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.version_files": { - "name": "version_files", - "schema": "", - "columns": { - "version_id": { - "name": "version_id", - "type": "bigint", + "permissions": { + "name": "permissions", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "file_hash": { - "name": "file_hash", + "status": { + "name": "status", "type": "text", "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "version_files_file_hash_idx": { - "name": "version_files_file_hash_idx", - "columns": [ - { - "expression": "file_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, + "indexes": {}, "foreignKeys": { - "version_files_version_id_versions_id_fk": { - "name": "version_files_version_id_versions_id_fk", - "tableFrom": "version_files", - "tableTo": "versions", - "columnsFrom": ["version_id"], + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "version_files_file_hash_files_hash_fk": { - "name": "version_files_file_hash_files_hash_fk", - "tableFrom": "version_files", - "tableTo": "files", - "columnsFrom": ["file_hash"], - "columnsTo": ["hash"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "version_files_version_id_file_hash_pk": { - "name": "version_files_version_id_file_hash_pk", - "columns": ["version_id", "file_hash"] } }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.version_records": { - "name": "version_records", - "schema": "", + "user": { + "name": "user", "columns": { - "version_id": { - "name": "version_id", - "type": "bigint", - "primaryKey": false, - "notNull": true + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false }, - "record_hash": { - "name": "record_hash", + "name": { + "name": "name", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "public_record_hash": { - "name": "public_record_hash", + "email": { + "name": "email", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "private": { - "name": "private", - "type": "boolean", + "email_verified": { + "name": "email_verified", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": false }, - "record_id": { - "name": "record_id", + "image": { + "name": "image", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false }, - "type": { - "name": "type", - "type": "text", + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "version_records_record_hash_idx": { - "name": "version_records_record_hash_idx", - "columns": [ - { - "expression": "record_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "version_records_public_record_hash_idx": { - "name": "version_records_public_record_hash_idx", - "columns": [ - { - "expression": "public_record_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "public_record_hash IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "version_records_version_record_idx": { - "name": "version_records_version_record_idx", - "columns": [ - { - "expression": "version_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "record_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "version_records_version_type_record_idx": { - "name": "version_records_version_type_record_idx", - "columns": [ - { - "expression": "version_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "record_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "version_records_version_id_versions_id_fk": { - "name": "version_records_version_id_versions_id_fk", - "tableFrom": "version_records", - "tableTo": "versions", - "columnsFrom": ["version_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, - "version_records_record_hash_record_objects_hash_fk": { - "name": "version_records_record_hash_record_objects_hash_fk", - "tableFrom": "version_records", - "tableTo": "record_objects", - "columnsFrom": ["record_hash"], - "columnsTo": ["hash"], - "onDelete": "no action", - "onUpdate": "no action" + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false } }, - "compositePrimaryKeys": { - "version_records_version_id_record_hash_pk": { - "name": "version_records_version_id_record_hash_pk", - "columns": ["version_id", "record_hash"] + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true } }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.version_schemas": { - "name": "version_schemas", - "schema": "", + "verification": { + "name": "verification", "columns": { - "version_id": { - "name": "version_id", - "type": "bigint", + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "slug": { - "name": "slug", + "value": { + "name": "value", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "schema_id": { - "name": "schema_id", - "type": "uuid", + "expires_at": { + "name": "expires_at", + "type": "integer", "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "version_schemas_schema_id_idx": { - "name": "version_schemas_schema_id_idx", - "columns": [ - { - "expression": "schema_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "version_schemas_version_id_versions_id_fk": { - "name": "version_schemas_version_id_versions_id_fk", - "tableFrom": "version_schemas", - "tableTo": "versions", - "columnsFrom": ["version_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "notNull": true, + "autoincrement": false }, - "version_schemas_schema_id_schemas_id_fk": { - "name": "version_schemas_schema_id_schemas_id_fk", - "tableFrom": "version_schemas", - "tableTo": "schemas", - "columnsFrom": ["schema_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false } }, - "compositePrimaryKeys": { - "version_schemas_version_id_slug_pk": { - "name": "version_schemas_version_id_slug_pk", - "columns": ["version_id", "slug"] + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false } }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.versions": { + "versions": { "name": "versions", - "schema": "", "columns": { "id": { "name": "id", - "type": "bigserial", + "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "semver": { "name": "semver", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "major": { "name": "major", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "minor": { "name": "minor", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "patch": { "name": "patch", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "hash": { "name": "hash", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, - "public_hash": { - "name": "public_hash", + "legacy_public_hash": { + "name": "legacy_public_hash", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "base_semver": { "name": "base_semver", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "message": { "name": "message", "type": "text", "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "pushed_by": { "name": "pushed_by", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "app_id": { "name": "app_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "actor_id": { "name": "actor_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "signature": { "name": "signature", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "record_count": { "name": "record_count", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false }, "file_count": { "name": "file_count", "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 }, "type_counts": { "name": "type_counts", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "total_bytes": { - "name": "total_bytes", - "type": "bigint", + "public_type_counts": { + "name": "public_type_counts", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, - "records_from_version_id": { - "name": "records_from_version_id", - "type": "bigint", + "has_private": { + "name": "has_private", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": false }, - "status": { - "name": "status", + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", "type": "text", "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, "notNull": true, - "default": "'ready'" + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" } }, "indexes": { - "versions_ordering_idx": { - "name": "versions_ordering_idx", - "columns": [ - { - "expression": "collection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "major", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "minor", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "patch", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "versions_records_from_version_idx": { - "name": "versions_records_from_version_idx", - "columns": [ - { - "expression": "records_from_version_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"versions\".\"records_from_version_id\" is not null", - "concurrently": false, - "method": "btree", - "with": {} + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false } }, "foreignKeys": { @@ -2761,102 +2815,78 @@ "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "versions_pushed_by_user_id_fk": { - "name": "versions_pushed_by_user_id_fk", - "tableFrom": "versions", - "tableTo": "user", - "columnsFrom": ["pushed_by"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "versions_records_from_version_id_versions_id_fk": { - "name": "versions_records_from_version_id_versions_id_fk", - "tableFrom": "versions", - "tableTo": "versions", - "columnsFrom": ["records_from_version_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "versions_collection_id_semver_unique": { - "name": "versions_collection_id_semver_unique", - "nullsNotDistinct": false, - "columns": ["collection_id", "semver"] - }, - "versions_collection_id_hash_public_hash_unique": { - "name": "versions_collection_id_hash_public_hash_unique", - "nullsNotDistinct": false, - "columns": ["collection_id", "hash", "public_hash"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "uniqueConstraints": {}, + "checkConstraints": {} }, - "public.webhook_deliveries": { + "webhook_deliveries": { "name": "webhook_deliveries", - "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true, - "default": "gen_random_uuid()" + "autoincrement": false }, "webhook_id": { "name": "webhook_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "collection_id": { "name": "collection_id", - "type": "uuid", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "version_id": { "name": "version_id", - "type": "bigint", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "semver": { "name": "semver", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "bump_type": { "name": "bump_type", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "event": { "name": "event", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'version.created'" }, "payload": { "name": "payload", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "status": { "name": "status", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'pending'" }, "attempts": { @@ -2864,112 +2894,63 @@ "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": 0 }, "response_code": { "name": "response_code", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "error": { "name": "error", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "duration_ms": { "name": "duration_ms", "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "next_attempt_at": { "name": "next_attempt_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "now()" + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" }, "delivered_at": { "name": "delivered_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": { - "webhook_deliveries_webhook_id_idx": { - "name": "webhook_deliveries_webhook_id_idx", - "columns": [ - { - "expression": "webhook_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "webhook_deliveries_collection_id_idx": { - "name": "webhook_deliveries_collection_id_idx", - "columns": [ - { - "expression": "collection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "webhook_deliveries_created_at_idx": { - "name": "webhook_deliveries_created_at_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "webhook_deliveries_sweep_idx": { - "name": "webhook_deliveries_sweep_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "next_attempt_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false } }, "foreignKeys": { @@ -2981,42 +2962,21 @@ "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "webhook_deliveries_collection_id_collections_id_fk": { - "name": "webhook_deliveries_collection_id_collections_id_fk", - "tableFrom": "webhook_deliveries", - "tableTo": "collections", - "columnsFrom": ["collection_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "webhook_deliveries_version_id_versions_id_fk": { - "name": "webhook_deliveries_version_id_versions_id_fk", - "tableFrom": "webhook_deliveries", - "tableTo": "versions", - "columnsFrom": ["version_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} } }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, "views": {}, + "enums": {}, "_meta": { - "columns": {}, "schemas": {}, - "tables": {} + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} } } diff --git a/packages/server/drizzle/meta/0003_snapshot.json b/packages/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..7040fe7 --- /dev/null +++ b/packages/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,3116 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2ee21f47-8c41-4bf4-9199-adaf2d75132b", + "prevId": "6702f02b-7423-4f01-844b-b8b701e35c83", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_target_location_uq": { + "name": "placements_target_location_uq", + "columns": ["collection_id", "organization_id", "location_id"], + "isUnique": true + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0004_snapshot.json b/packages/server/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..db1f0ad --- /dev/null +++ b/packages/server/drizzle/meta/0004_snapshot.json @@ -0,0 +1,3130 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "05eeb8ec-998e-4d87-9f29-ea8659923a3a", + "prevId": "2ee21f47-8c41-4bf4-9199-adaf2d75132b", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_target_location_uq": { + "name": "placements_target_location_uq", + "columns": ["collection_id", "organization_id", "location_id"], + "isUnique": true + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0005_snapshot.json b/packages/server/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..ea6e9a8 --- /dev/null +++ b/packages/server/drizzle/meta/0005_snapshot.json @@ -0,0 +1,3269 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0ec959f1-7ff5-481e-a08e-94634551c68c", + "prevId": "05eeb8ec-998e-4d87-9f29-ea8659923a3a", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_target_location_uq": { + "name": "placements_target_location_uq", + "columns": ["collection_id", "organization_id", "location_id"], + "isUnique": true + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0006_snapshot.json b/packages/server/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..5c23a02 --- /dev/null +++ b/packages/server/drizzle/meta/0006_snapshot.json @@ -0,0 +1,3277 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7e9f4ab3-fb20-4e9b-a7c2-e816cd45325d", + "prevId": "0ec959f1-7ff5-481e-a08e-94634551c68c", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_target_location_uq": { + "name": "placements_target_location_uq", + "columns": ["collection_id", "organization_id", "location_id"], + "isUnique": true + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0007_snapshot.json b/packages/server/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..79091ff --- /dev/null +++ b/packages/server/drizzle/meta/0007_snapshot.json @@ -0,0 +1,3284 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b44df269-c8df-4fdb-8980-149b725be72d", + "prevId": "7e9f4ab3-fb20-4e9b-a7c2-e816cd45325d", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0008_snapshot.json b/packages/server/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..e26670b --- /dev/null +++ b/packages/server/drizzle/meta/0008_snapshot.json @@ -0,0 +1,3372 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cf7c2848-ebb8-4f5f-9680-f39e8e256522", + "prevId": "b44df269-c8df-4fdb-8980-149b725be72d", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0009_snapshot.json b/packages/server/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..edf0cc8 --- /dev/null +++ b/packages/server/drizzle/meta/0009_snapshot.json @@ -0,0 +1,3389 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5abcd331-5ddf-4d74-adcf-b31a009c2842", + "prevId": "cf7c2848-ebb8-4f5f-9680-f39e8e256522", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0010_snapshot.json b/packages/server/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..cbd7ee9 --- /dev/null +++ b/packages/server/drizzle/meta/0010_snapshot.json @@ -0,0 +1,3462 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "22943ec2-6ed6-4269-b6e0-ea5a6dc017c0", + "prevId": "5abcd331-5ddf-4d74-adcf-b31a009c2842", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0011_snapshot.json b/packages/server/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..87052d4 --- /dev/null +++ b/packages/server/drizzle/meta/0011_snapshot.json @@ -0,0 +1,3511 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9b24bd33-276f-4550-b330-7b3f0bf58806", + "prevId": "22943ec2-6ed6-4269-b6e0-ea5a6dc017c0", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0012_snapshot.json b/packages/server/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..3c6912a --- /dev/null +++ b/packages/server/drizzle/meta/0012_snapshot.json @@ -0,0 +1,3567 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "29dd3fc8-896c-491e-af0e-60d8830283ef", + "prevId": "9b24bd33-276f-4550-b330-7b3f0bf58806", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0013_snapshot.json b/packages/server/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..130acd3 --- /dev/null +++ b/packages/server/drizzle/meta/0013_snapshot.json @@ -0,0 +1,3574 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8aa587af-6f17-4bbe-9363-32950e69ddae", + "prevId": "29dd3fc8-896c-491e-af0e-60d8830283ef", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_comments": { + "name": "page_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quote_context": { + "name": "quote_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "edited_at": { + "name": "edited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "page_comments_page_idx": { + "name": "page_comments_page_idx", + "columns": ["page"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0014_snapshot.json b/packages/server/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..447a804 --- /dev/null +++ b/packages/server/drizzle/meta/0014_snapshot.json @@ -0,0 +1,3451 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9f8e22ed-5bb3-4450-b7aa-79d946873caa", + "prevId": "8aa587af-6f17-4bbe-9363-32950e69ddae", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0015_snapshot.json b/packages/server/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..c0ad064 --- /dev/null +++ b/packages/server/drizzle/meta/0015_snapshot.json @@ -0,0 +1,3646 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3b941038-b8ae-4879-b9ab-579e9612d18a", + "prevId": "9f8e22ed-5bb3-4450-b7aa-79d946873caa", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cleanup_runs": { + "name": "cleanup_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mark_run_id": { + "name": "mark_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease": { + "name": "lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "cleanup_runs_created_idx": { + "name": "cleanup_runs_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "cleanup_runs_step_idx": { + "name": "cleanup_runs_step_idx", + "columns": ["step", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_expected": { + "name": "manifest_expected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_received": { + "name": "manifest_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "manifest_needed": { + "name": "manifest_needed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_fence": { + "name": "storage_fence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "window_until": { + "name": "window_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window_run_id": { + "name": "window_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + }, + "versions_published_idx": { + "name": "versions_published_idx", + "columns": ["published_at"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0016_snapshot.json b/packages/server/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..8f1a1f5 --- /dev/null +++ b/packages/server/drizzle/meta/0016_snapshot.json @@ -0,0 +1,3616 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3c2b7d37-a304-4152-bb81-a47cf12af99e", + "prevId": "3b941038-b8ae-4879-b9ab-579e9612d18a", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cleanup_runs": { + "name": "cleanup_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mark_run_id": { + "name": "mark_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease": { + "name": "lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "cleanup_runs_created_idx": { + "name": "cleanup_runs_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "cleanup_runs_step_idx": { + "name": "cleanup_runs_step_idx", + "columns": ["step", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_hashes": { + "name": "legacy_hashes", + "columns": { + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_fence": { + "name": "storage_fence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "window_until": { + "name": "window_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window_run_id": { + "name": "window_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "legacy_hash": { + "name": "legacy_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_public_hash": { + "name": "legacy_public_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_legacy_hash_idx": { + "name": "versions_legacy_hash_idx", + "columns": ["legacy_hash"], + "isUnique": false + }, + "versions_legacy_public_hash_idx": { + "name": "versions_legacy_public_hash_idx", + "columns": ["legacy_public_hash"], + "isUnique": false + }, + "versions_published_idx": { + "name": "versions_published_idx", + "columns": ["published_at"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0017_snapshot.json b/packages/server/drizzle/meta/0017_snapshot.json new file mode 100644 index 0000000..7895c55 --- /dev/null +++ b/packages/server/drizzle/meta/0017_snapshot.json @@ -0,0 +1,3561 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3e235332-6405-4589-a7a6-f575507b52eb", + "prevId": "3c2b7d37-a304-4152-bb81-a47cf12af99e", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cleanup_runs": { + "name": "cleanup_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mark_run_id": { + "name": "mark_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease": { + "name": "lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "cleanup_runs_created_idx": { + "name": "cleanup_runs_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "cleanup_runs_step_idx": { + "name": "cleanup_runs_step_idx", + "columns": ["step", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_collection_id": { + "name": "source_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trust_key_ids": { + "name": "trust_key_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "restored_seq": { + "name": "restored_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_entry_hash": { + "name": "last_entry_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version_hash": { + "name": "last_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_organization_id_organization_id_fk": { + "name": "restores_organization_id_organization_id_fk", + "tableFrom": "restores", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_location_id_storage_locations_id_fk": { + "name": "restores_location_id_storage_locations_id_fk", + "tableFrom": "restores", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restores_collection_id_collections_id_fk": { + "name": "restores_collection_id_collections_id_fk", + "tableFrom": "restores", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_fence": { + "name": "storage_fence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "window_until": { + "name": "window_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window_run_id": { + "name": "window_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_published_idx": { + "name": "versions_published_idx", + "columns": ["published_at"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0018_snapshot.json b/packages/server/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..f538f46 --- /dev/null +++ b/packages/server/drizzle/meta/0018_snapshot.json @@ -0,0 +1,3415 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "465cc7fa-529a-461c-aead-aa4c2671021c", + "prevId": "3e235332-6405-4589-a7a6-f575507b52eb", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cleanup_runs": { + "name": "cleanup_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mark_run_id": { + "name": "mark_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease": { + "name": "lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "cleanup_runs_created_idx": { + "name": "cleanup_runs_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "cleanup_runs_step_idx": { + "name": "cleanup_runs_step_idx", + "columns": ["step", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_fence": { + "name": "storage_fence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "window_until": { + "name": "window_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window_run_id": { + "name": "window_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_published_idx": { + "name": "versions_published_idx", + "columns": ["published_at"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0019_snapshot.json b/packages/server/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000..c2eecba --- /dev/null +++ b/packages/server/drizzle/meta/0019_snapshot.json @@ -0,0 +1,3415 @@ +{ + "id": "96f20569-649d-4b93-ba3f-6512e9df90c7", + "prevId": "465cc7fa-529a-461c-aead-aa4c2671021c", + "version": "6", + "dialect": "sqlite", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cleanup_runs": { + "name": "cleanup_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mark_run_id": { + "name": "mark_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease": { + "name": "lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "cleanup_runs_created_idx": { + "name": "cleanup_runs_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "cleanup_runs_step_idx": { + "name": "cleanup_runs_step_idx", + "columns": ["step", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "columnsFrom": ["session_id"], + "tableTo": "push_sessions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "columnsFrom": ["child_collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "columnsFrom": ["inviter_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL", + "isUnique": true + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "where": "\"placements\".\"collection_id\" IS NOT NULL", + "isUnique": true + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "where": "\"placements\".\"organization_id\" IS NOT NULL", + "isUnique": true + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "columnsFrom": ["location_id"], + "tableTo": "storage_locations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "columnsFrom": ["session_id"], + "tableTo": "push_sessions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_fence": { + "name": "storage_fence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "window_until": { + "name": "window_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window_run_id": { + "name": "window_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_published_idx": { + "name": "versions_published_idx", + "columns": ["published_at"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "columnsFrom": ["collection_id"], + "tableTo": "collections", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "columnsFrom": ["webhook_id"], + "tableTo": "collection_webhooks", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0020_snapshot.json b/packages/server/drizzle/meta/0020_snapshot.json new file mode 100644 index 0000000..00ade96 --- /dev/null +++ b/packages/server/drizzle/meta/0020_snapshot.json @@ -0,0 +1,3437 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6ea4fc68-49da-4543-820e-03ff68b7177f", + "prevId": "96f20569-649d-4b93-ba3f-6512e9df90c7", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cleanup_runs": { + "name": "cleanup_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mark_run_id": { + "name": "mark_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease": { + "name": "lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "cleanup_runs_created_idx": { + "name": "cleanup_runs_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "cleanup_runs_step_idx": { + "name": "cleanup_runs_step_idx", + "columns": ["step", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_state": { + "name": "reconcile_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_seq": { + "name": "reconciled_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconciled_files_root": { + "name": "reconciled_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_fence": { + "name": "storage_fence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "window_until": { + "name": "window_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window_run_id": { + "name": "window_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_published_idx": { + "name": "versions_published_idx", + "columns": ["published_at"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0021_snapshot.json b/packages/server/drizzle/meta/0021_snapshot.json new file mode 100644 index 0000000..0dad5a3 --- /dev/null +++ b/packages/server/drizzle/meta/0021_snapshot.json @@ -0,0 +1,3480 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "40cfb0a7-89fa-4f1f-8139-ee6a678ed856", + "prevId": "6ea4fc68-49da-4543-820e-03ff68b7177f", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cleanup_runs": { + "name": "cleanup_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mark_run_id": { + "name": "mark_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease": { + "name": "lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "cleanup_runs_created_idx": { + "name": "cleanup_runs_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "cleanup_runs_step_idx": { + "name": "cleanup_runs_step_idx", + "columns": ["step", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_state": { + "name": "reconcile_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_seq": { + "name": "reconciled_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconciled_files_root": { + "name": "reconciled_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_index_units": { + "name": "ref_index_units", + "columns": { + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_index_units_version_id_unit_pk": { + "columns": ["version_id", "unit"], + "name": "ref_index_units_version_id_unit_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_fence": { + "name": "storage_fence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "window_until": { + "name": "window_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window_run_id": { + "name": "window_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_published_idx": { + "name": "versions_published_idx", + "columns": ["published_at"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0022_snapshot.json b/packages/server/drizzle/meta/0022_snapshot.json new file mode 100644 index 0000000..34b2168 --- /dev/null +++ b/packages/server/drizzle/meta/0022_snapshot.json @@ -0,0 +1,3503 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "341ffda6-5223-48e0-b672-4b357828c96e", + "prevId": "40cfb0a7-89fa-4f1f-8139-ee6a678ed856", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cleanup_runs": { + "name": "cleanup_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mark_run_id": { + "name": "mark_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease": { + "name": "lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "cleanup_runs_created_idx": { + "name": "cleanup_runs_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "cleanup_runs_step_idx": { + "name": "cleanup_runs_step_idx", + "columns": ["step", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "version_count": { + "name": "version_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "history_bytes": { + "name": "history_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_push_at": { + "name": "last_push_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_state": { + "name": "reconcile_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_seq": { + "name": "reconciled_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconciled_files_root": { + "name": "reconciled_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_index_units": { + "name": "ref_index_units", + "columns": { + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_index_units_version_id_unit_pk": { + "columns": ["version_id", "unit"], + "name": "ref_index_units_version_id_unit_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_fence": { + "name": "storage_fence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "window_until": { + "name": "window_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window_run_id": { + "name": "window_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_published_idx": { + "name": "versions_published_idx", + "columns": ["published_at"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/0023_snapshot.json b/packages/server/drizzle/meta/0023_snapshot.json new file mode 100644 index 0000000..234bace --- /dev/null +++ b/packages/server/drizzle/meta/0023_snapshot.json @@ -0,0 +1,3515 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e71a382c-fa6e-4adb-8677-16e126744817", + "prevId": "341ffda6-5223-48e0-b672-4b357828c96e", + "tables": { + "abuse_reports": { + "name": "abuse_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "abuse_reports_status_idx": { + "name": "abuse_reports_status_idx", + "columns": ["status", "created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": ["key"], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": ["reference_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_collections": { + "name": "ark_collections", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "columns": ["ark_id"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_record_types": { + "name": "ark_record_types", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "columns": ["collection_id", "record_type"], + "name": "ark_record_types_collection_id_record_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ark_shoulders": { + "name": "ark_shoulders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "columns": ["shoulder"], + "isUnique": true + } + }, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cleanup_runs": { + "name": "cleanup_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mark_run_id": { + "name": "mark_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease": { + "name": "lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "cleanup_runs_created_idx": { + "name": "cleanup_runs_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "cleanup_runs_step_idx": { + "name": "cleanup_runs_step_idx", + "columns": ["step", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_tombstones": { + "name": "collection_tombstones", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_webhooks": { + "name": "collection_webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bump_filter": { + "name": "bump_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_webhooks_collection_idx": { + "name": "collection_webhooks_collection_idx", + "columns": ["collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_version_id": { + "name": "head_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_salt": { + "name": "private_salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_files_root": { + "name": "public_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "version_count": { + "name": "version_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "history_bytes": { + "name": "history_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_push_at": { + "name": "last_push_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_started_at": { + "name": "reconcile_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_state": { + "name": "reconcile_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_seq": { + "name": "reconciled_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reconciled_files_root": { + "name": "reconciled_files_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collections_org_slug_uq": { + "name": "collections_org_slug_uq", + "columns": ["organization_id", "slug"], + "isUnique": true + }, + "collections_public_updated_idx": { + "name": "collections_public_updated_idx", + "columns": ["public", "updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commit_units": { + "name": "commit_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "through": { + "name": "through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gap": { + "name": "gap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "slices_key": { + "name": "slices_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_key": { + "name": "output_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "commit_units_plan_idx": { + "name": "commit_units_plan_idx", + "columns": ["plan_id", "set", "type", "ord"], + "isUnique": false + }, + "commit_units_status_idx": { + "name": "commit_units_status_idx", + "columns": ["session_id", "status", "queued_at"], + "isUnique": false + } + }, + "foreignKeys": { + "commit_units_session_id_push_sessions_id_fk": { + "name": "commit_units_session_id_push_sessions_id_fk", + "tableFrom": "commit_units", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "denylist": { + "name": "denylist", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_uploads": { + "name": "file_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "file_uploads_hash_idx": { + "name": "file_uploads_hash_idx", + "columns": ["hash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forks": { + "name": "forks", + "columns": { + "child_collection_id": { + "name": "child_collection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_collection_id": { + "name": "parent_collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_seq": { + "name": "parent_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "forks_child_collection_id_collections_id_fk": { + "name": "forks_child_collection_id_collections_id_fk", + "tableFrom": "forks", + "tableTo": "collections", + "columnsFrom": ["child_collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "jobs_ready_idx": { + "name": "jobs_ready_idx", + "columns": ["status", "run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": ["organization_id"], + "isUnique": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "placements": { + "name": "placements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sets": { + "name": "sets", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "synced_seq": { + "name": "synced_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "placements_one_primary_uq": { + "name": "placements_one_primary_uq", + "columns": ["collection_id"], + "isUnique": true, + "where": "\"placements\".\"role\" = 'primary' AND \"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_collection_location_uq": { + "name": "placements_collection_location_uq", + "columns": ["collection_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"collection_id\" IS NOT NULL" + }, + "placements_org_location_uq": { + "name": "placements_org_location_uq", + "columns": ["organization_id", "location_id"], + "isUnique": true, + "where": "\"placements\".\"organization_id\" IS NOT NULL" + }, + "placements_location_idx": { + "name": "placements_location_idx", + "columns": ["location_id"], + "isUnique": false + } + }, + "foreignKeys": { + "placements_collection_id_collections_id_fk": { + "name": "placements_collection_id_collections_id_fk", + "tableFrom": "placements", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_organization_id_organization_id_fk": { + "name": "placements_organization_id_organization_id_fk", + "tableFrom": "placements", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "placements_location_id_storage_locations_id_fk": { + "name": "placements_location_id_storage_locations_id_fk", + "tableFrom": "placements", + "tableTo": "storage_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_runs": { + "name": "push_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_key": { + "name": "first_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_key": { + "name": "last_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merging_into": { + "name": "merging_into", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_runs_tier_idx": { + "name": "push_runs_tier_idx", + "columns": ["session_id", "tier", "merging_into"], + "isUnique": false + } + }, + "foreignKeys": { + "push_runs_session_id_push_sessions_id_fk": { + "name": "push_runs_session_id_push_sessions_id_fk", + "tableFrom": "push_runs", + "tableTo": "push_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_runs_session_id_seq_pk": { + "columns": ["session_id", "seq"], + "name": "push_runs_session_id_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_sessions": { + "name": "push_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_version_id": { + "name": "base_version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "records_received": { + "name": "records_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "runs": { + "name": "runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_plan": { + "name": "commit_plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assembly_lease": { + "name": "assembly_lease", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_sessions_collection_idx": { + "name": "push_sessions_collection_idx", + "columns": ["collection_id"], + "isUnique": false + }, + "push_sessions_expires_idx": { + "name": "push_sessions_expires_idx", + "columns": ["status", "expires_at"], + "isUnique": false + }, + "push_sessions_user_idx": { + "name": "push_sessions_user_idx", + "columns": ["user_id", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "push_sessions_collection_id_collections_id_fk": { + "name": "push_sessions_collection_id_collections_id_fk", + "tableFrom": "push_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compaction_parts": { + "name": "ref_compaction_parts", + "columns": { + "compaction_id": { + "name": "compaction_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_compaction_parts_compaction_id_part_pk": { + "columns": ["compaction_id", "part"], + "name": "ref_compaction_parts_compaction_id_part_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_compactions": { + "name": "ref_compactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_runs": { + "name": "input_runs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_run": { + "name": "output_run", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_index_units": { + "name": "ref_index_units", + "columns": { + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ref_index_units_version_id_unit_pk": { + "columns": ["version_id", "unit"], + "name": "ref_index_units_version_id_unit_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ref_segments": { + "name": "ref_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_hash": { + "name": "first_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_hash": { + "name": "last_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'live'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "ref_segments_range_idx": { + "name": "ref_segments_range_idx", + "columns": ["state", "first_hash", "last_hash"], + "isUnique": false + }, + "ref_segments_run_idx": { + "name": "ref_segments_run_idx", + "columns": ["run_id"], + "isUnique": false + }, + "ref_segments_tier_idx": { + "name": "ref_segments_tier_idx", + "columns": ["tier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_labels": { + "name": "schema_labels", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": ["label"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schema_labels_schema_hash_label_pk": { + "columns": ["schema_hash", "label"], + "name": "schema_labels_schema_hash_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_usage": { + "name": "schema_usage", + "columns": { + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_slug": { + "name": "type_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set": { + "name": "set", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_seq": { + "name": "to_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schema_usage_hash_idx": { + "name": "schema_usage_hash_idx", + "columns": ["schema_hash"], + "isUnique": false + } + }, + "foreignKeys": { + "schema_usage_collection_id_collections_id_fk": { + "name": "schema_usage_collection_id_collections_id_fk", + "tableFrom": "schema_usage", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "schema_usage_collection_id_type_slug_set_from_seq_pk": { + "columns": ["collection_id", "type_slug", "set", "from_seq"], + "name": "schema_usage_collection_id_type_slug_set_from_seq_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schemas": { + "name": "schemas", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_fence": { + "name": "storage_fence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "window_until": { + "name": "window_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window_run_id": { + "name": "window_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_locations": { + "name": "storage_locations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "storage_locations_organization_id_organization_id_fk": { + "name": "storage_locations_organization_id_organization_id_fk", + "tableFrom": "storage_locations", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_rollups": { + "name": "usage_rollups", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "usage_rollups_account_idx": { + "name": "usage_rollups_account_idx", + "columns": ["account_id", "day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_rollups_day_account_id_collection_id_metric_pk": { + "columns": ["day", "account_id", "collection_id", "metric"], + "name": "usage_rollups_day_account_id_collection_id_metric_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "versions": { + "name": "versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_record_count": { + "name": "public_record_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_bytes": { + "name": "total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_file_count": { + "name": "public_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_total_bytes": { + "name": "public_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type_counts": { + "name": "type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_type_counts": { + "name": "public_type_counts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_private": { + "name": "has_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "public_refs_root": { + "name": "public_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_refs_root": { + "name": "private_refs_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refs_indexed": { + "name": "refs_indexed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ref_events": { + "name": "ref_events", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_bytes": { + "name": "ref_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconcile_report": { + "name": "reconcile_report", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changes": { + "name": "changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "push_session_id": { + "name": "push_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "versions_collection_seq_uq": { + "name": "versions_collection_seq_uq", + "columns": ["collection_id", "seq"], + "isUnique": true + }, + "versions_collection_semver_uq": { + "name": "versions_collection_semver_uq", + "columns": ["collection_id", "semver"], + "isUnique": true + }, + "versions_hash_idx": { + "name": "versions_hash_idx", + "columns": ["hash"], + "isUnique": false + }, + "versions_published_idx": { + "name": "versions_published_idx", + "columns": ["published_at"], + "isUnique": false + }, + "versions_push_session_idx": { + "name": "versions_push_session_idx", + "columns": ["push_session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_deliveries_webhook_idx": { + "name": "webhook_deliveries_webhook_idx", + "columns": ["webhook_id", "created_at"], + "isUnique": false + }, + "webhook_deliveries_pending_idx": { + "name": "webhook_deliveries_pending_idx", + "columns": ["status", "next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..a018012 --- /dev/null +++ b/packages/server/drizzle/meta/_journal.json @@ -0,0 +1,174 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1791062646832, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1791062647591, + "tag": "0001_platform_location", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1791068580547, + "tag": "0002_run_tiers", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1791068993505, + "tag": "0003_parallel_commit", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1791075583544, + "tag": "0004_mirror_progress", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1791075872554, + "tag": "0005_restores", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1791084344660, + "tag": "0006_fork_sets", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1791084798353, + "tag": "0007_placements_unique", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1791122977878, + "tag": "0008_abuse_reports", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1791124589161, + "tag": "0009_push_caps", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1791125322468, + "tag": "0010_collection_tombstones", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1791125438016, + "tag": "0011_reconcile", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1791125622343, + "tag": "0012_usage_rollups", + "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1791127142531, + "tag": "0013_location_checked_at", + "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1791135921639, + "tag": "0014_drop_page_comments", + "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1791140500294, + "tag": "0015_storage_cleanup", + "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1791144083303, + "tag": "0016_drop_negotiate", + "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1791144123221, + "tag": "0017_drop_v1_hashes", + "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1791144719615, + "tag": "0018_drop_restore", + "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1791144968727, + "tag": "0019_tombstone_trigger", + "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1791145338581, + "tag": "0020_reconcile_state", + "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1791145611798, + "tag": "0021_ref_index_units", + "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1791147682002, + "tag": "0022_collection_history", + "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1791231702996, + "tag": "0023_version_push_session", + "breakpoints": true + } + ] +} diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..f2ad265 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,36 @@ +{ + "name": "@underlay/server", + "version": "0.0.0", + "private": true, + "description": "Underlay v2 server: one Hono app over blob, SQLite, jobs and cache ports. Runs on Cloudflare Workers and on Node.", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "dev:node": "tsx watch src/node/main.ts" + }, + "dependencies": { + "@better-auth/api-key": "^1.6.11", + "@hono/node-server": "^1.19.14", + "@libsql/client": "^0.18.0", + "@underlay/protocol": "workspace:*", + "@underlay/web": "workspace:*", + "better-auth": "^1.6.11", + "drizzle-orm": "^0.45.2", + "hono": "^4.12.18" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20261003.1", + "@types/node": "^25.0.0", + "drizzle-kit": "^0.31.10", + "fast-check": "^4.10.2", + "tsx": "^4.19.0", + "typescript": "^6.0.0", + "vitest": "^4.1.6", + "wrangler": "^4.147.0" + } +} diff --git a/packages/server/scripts/load.ts b/packages/server/scripts/load.ts new file mode 100644 index 0000000..5ebd878 --- /dev/null +++ b/packages/server/scripts/load.ts @@ -0,0 +1,238 @@ +/** + * Load test against a running deployment (v2-scale-review.md, "Load test"): + * concurrent delta pushes, a stream of small commits, and + * anonymous page and API reads, all at once. Reports latency percentiles, + * status counts and commit times as JSON on stdout. + * + * UL_KEYS=ul_a,ul_b npx tsx scripts/load.ts [options] + * + * --delta N concurrent delta pushes (default 2) + * --records M records per delta push (default 20000) + * --small N small commits, one after another (default 10) + * --reads N anonymous reads, eight at a time (default 100) + * --read-path P what to read (default /explore; repeat for more) + * --cleanup delete the collections it made afterwards + * + * Keys come from UL_KEYS (comma-separated), never the command line; pushes take + * them in turn. Each user may hold 20 sessions at once, so a full run (20 delta + * pushes and more) needs several users' keys. Every collection it makes is + * private and named `load--…` under , which the keys must be able + * to write to. + */ + +const [baseArg, owner, ...rest] = process.argv.slice(2) +const keys = (process.env.UL_KEYS ?? '').split(',').filter(Boolean) +if (!baseArg || !owner || keys.length === 0) { + console.error('usage: UL_KEYS=ul_… load.ts [--delta N] [--records M] …') + process.exit(2) +} +const base = baseArg.replace(/\/$/, '') +const opt = (name: string, def: number) => { + const i = rest.indexOf(`--${name}`) + return i >= 0 ? Number(rest[i + 1]) : def +} +const readPaths = rest.flatMap((a, i) => (rest[i - 1] === '--read-path' ? [a] : [])) +const cfg = { + delta: opt('delta', 2), + records: opt('records', 20_000), + small: opt('small', 10), + reads: opt('reads', 100), + readPaths: readPaths.length ? readPaths : ['/explore'], + cleanup: rest.includes('--cleanup'), +} +const run = Date.now().toString(36) +let turn = 0 +const nextKey = () => keys[turn++ % keys.length]! + +// --- measurement -------------------------------------------------------------------- + +const samples = new Map() +const statuses = new Map>() +function note(kind: string, ms: number, status: number) { + ;(samples.get(kind) ?? samples.set(kind, []).get(kind)!).push(ms) + const m = statuses.get(kind) ?? statuses.set(kind, new Map()).get(kind)! + m.set(status, (m.get(status) ?? 0) + 1) +} +const pct = (xs: number[], p: number) => { + const s = [...xs].sort((a, b) => a - b) + return s.length ? Math.round(s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]!) : null +} + +async function call( + kind: string, + key: string | null, + method: string, + path: string, + body?: unknown, + ndjson?: string, +): Promise<{ status: number; json: Record }> { + const t = performance.now() + for (let attempt = 0; ; attempt++) { + const res = await fetch(`${base}${path}`, { + method, + headers: { + ...(key ? { authorization: `Bearer ${key}` } : {}), + 'content-type': ndjson !== undefined ? 'application/x-ndjson' : 'application/json', + }, + ...(ndjson !== undefined + ? { body: ndjson } + : body !== undefined + ? { body: JSON.stringify(body) } + : {}), + }) + const text = await res.text() + // Budgets are per minute: wait them out, but count the 429. + if (res.status === 429 && attempt < 5) { + note(`${kind} (429 retried)`, 0, 429) + await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 10) * 1000)) + continue + } + note(kind, performance.now() - t, res.status) + let json: Record = {} + try { + json = text ? (JSON.parse(text) as Record) : {} + } catch { + json = { text: text.slice(0, 200) } + } + return { status: res.status, json } + } +} + +// --- workloads ------------------------------------------------------------------------ + +const Item = { + type: 'object', + properties: { n: { type: 'integer' }, label: { type: 'string' }, tags: { type: 'array' } }, +} +const record = (i: number, salt: string) => ({ + id: `i${String(i).padStart(9, '0')}`, + type: 'Item', + data: { n: i, label: `${salt}-${i}`, tags: [`t${i % 17}`, `u${i % 101}`] }, +}) +const made: { key: string; slug: string }[] = [] + +async function collection(key: string, slug: string) { + const r = await call('collection create', key, 'POST', `/api/accounts/${owner}/collections`, { + slug, + name: slug, + public: false, + }) + if (r.status >= 300) throw new Error(`create ${slug}: ${r.status} ${JSON.stringify(r.json)}`) + made.push({ key, slug }) + return `/api/collections/${owner}/${slug}` +} + +/** Wait for an async commit; returns ms from commit request to committed. */ +async function settle(kind: string, key: string, statusPath: string, t0: number) { + for (;;) { + const s = await call(`${kind} poll`, key, 'GET', statusPath) + const st = s.json.status + // A commit refused for missing files reopens the session, with the error. + if (st === 'committed' || st === 'failed' || st === 'expired' || st === 'open') { + note(`${kind} commit→${st}`, performance.now() - t0, 200) + return st + } + await new Promise((r) => setTimeout(r, 2000)) + } +} + +async function deltaPush(i: number) { + const key = nextKey() + const col = await collection(key, `load-${run}-d${i}`) + const open = await call('delta open', key, 'POST', `${col}/push`, { schemas: { Item } }) + const sid = open.json.session_id as string + const BATCH = 10_000 + for (let at = 0; at < cfg.records; at += BATCH) { + const lines = [] + for (let j = at; j < Math.min(cfg.records, at + BATCH); j++) + lines.push(JSON.stringify(record(j, `d${i}`))) + await call( + 'delta records batch', + key, + 'POST', + `${col}/push/${sid}/records`, + undefined, + lines.join('\n'), + ) + } + const t0 = performance.now() + await call('delta commit', key, 'POST', `${col}/push/${sid}/commit?async=1`, {}) + await settle('delta', key, `${col}/push/${sid}`, t0) +} + +async function smallCommits() { + const key = nextKey() + const col = await collection(key, `load-${run}-small`) + for (let i = 0; i < cfg.small; i++) { + const open = await call( + 'small open', + key, + 'POST', + `${col}/push`, + i === 0 ? { schemas: { Item } } : {}, + ) + const sid = open.json.session_id as string + const lines = Array.from({ length: 10 }, (_, j) => JSON.stringify(record(i * 10 + j, 's'))) + await call( + 'small records', + key, + 'POST', + `${col}/push/${sid}/records`, + undefined, + lines.join('\n'), + ) + await call('small commit (sync)', key, 'POST', `${col}/push/${sid}/commit`, {}) + } +} + +async function reads() { + let next = 0 + await Promise.all( + Array.from({ length: 8 }, async () => { + while (next < cfg.reads) { + const path = cfg.readPaths[next++ % cfg.readPaths.length]! + await call(`read ${path}`, null, 'GET', path) + } + }), + ) +} + +// --- run ------------------------------------------------------------------------------ + +const started = performance.now() +const results = await Promise.allSettled([ + ...Array.from({ length: cfg.delta }, (_, i) => deltaPush(i)), + smallCommits(), + reads(), +]) +const failures = results.flatMap((r) => (r.status === 'rejected' ? [String(r.reason)] : [])) + +if (cfg.cleanup) { + for (const m of made) { + await call('collection delete', m.key, 'DELETE', `/api/collections/${owner}/${m.slug}`) + } +} + +console.log( + JSON.stringify( + { + run, + config: cfg, + seconds: Math.round((performance.now() - started) / 1000), + failures, + collections: made.map((m) => `${owner}/${m.slug}`), + cleanedUp: cfg.cleanup, + ms: Object.fromEntries( + [...samples].map(([k, xs]) => [ + k, + { n: xs.length, p50: pct(xs, 50), p99: pct(xs, 99), max: pct(xs, 100) }, + ]), + ), + statuses: Object.fromEntries([...statuses].map(([k, m]) => [k, Object.fromEntries(m)])), + }, + null, + 2, + ), +) + +export {} diff --git a/packages/server/scripts/smoke.ts b/packages/server/scripts/smoke.ts new file mode 100644 index 0000000..7ed18f5 --- /dev/null +++ b/packages/server/scripts/smoke.ts @@ -0,0 +1,114 @@ +/** + * End-to-end smoke test against a running deployment (local `wrangler dev`, the + * Node server, or staging): + * + * npx tsx scripts/smoke.ts + * + * Pushes with the delta API (sync and async commits, and a full snapshot diffed + * against the manifest), then checks the head moved. Exits non-zero on any unexpected response. + */ +const [baseUrl, key, collection] = process.argv.slice(2) +if (!baseUrl || !key || !collection) { + console.error('usage: smoke.ts ') + process.exit(2) +} +const api = `${baseUrl.replace(/\/$/, '')}/api/collections/${collection}` +const auth = { authorization: `Bearer ${key}` } + +async function call(method: string, path: string, body?: unknown, ndjson = false) { + const res = await fetch(`${api}${path}`, { + method, + headers: { ...auth, 'content-type': ndjson ? 'application/x-ndjson' : 'application/json' }, + ...(body === undefined + ? {} + : { + body: ndjson + ? (body as unknown[]).map((l) => JSON.stringify(l)).join('\n') + : JSON.stringify(body), + }), + }) + const text = await res.text() + let json: any + try { + json = JSON.parse(text) + } catch { + json = text + } + console.log(`${method} ${path} → ${res.status}`, JSON.stringify(json).slice(0, 300)) + return { status: res.status, json } +} + +function expect(cond: unknown, what: string) { + if (!cond) { + console.error(`FAILED: ${what}`) + process.exit(1) + } +} + +const Author = { + type: 'object', + properties: { name: { type: 'string' }, born: { type: 'integer' } }, +} +const stamp = Date.now() + +// 1. Delta push, synchronous commit. +let r = await call('POST', '/push', { schemas: { Author }, metadata: { title: `Smoke ${stamp}` } }) +expect(r.status === 200, 'open delta session') +let sid = r.json.session_id +r = await call( + 'POST', + `/push/${sid}/records`, + [ + { id: `ada-${stamp}`, type: 'Author', data: { name: 'Ada', born: 1815 } }, + { id: `alan-${stamp}`, type: 'Author', data: { name: 'Alan', born: 1912 } }, + { id: `kurt-${stamp}`, type: 'Author', data: { name: 'Kurt' }, private: true }, + ], + true, +) +expect(r.status === 200 && r.json.received === 3, 'upload records') +r = await call('POST', `/push/${sid}/commit`) +expect(r.status === 201, 'sync commit') +const first = r.json.semver as string + +// 2. Delta push, async commit (a job on Queues / the jobs table). +r = await call('POST', '/push', { base: first }) +sid = r.json.session_id +await call('POST', `/push/${sid}/deletes`, [{ type: 'Author', id: `alan-${stamp}` }], true) +r = await call('POST', `/push/${sid}/commit?async=true`) +expect(r.status === 202, 'async commit accepted') +let status = '' +for (let i = 0; i < 60 && status !== 'committed' && status !== 'failed'; i++) { + await new Promise((res) => setTimeout(res, 1000)) + status = (await call('GET', `/push/${sid}`)).json.status +} +expect(status === 'committed', 'async commit finished') + +// 3. A client that keeps no copy: read the head's manifest, diff the full +// snapshot against it, and push only the differences. +const { hashRecord } = await import('@underlay/protocol') +const snapshot = [ + { id: `ada-${stamp}`, type: 'Author', data: { name: 'Ada Lovelace', born: 1815 } }, + { id: `grace-${stamp}`, type: 'Author', data: { name: 'Grace', born: 1906, 10: 'x', 9: 'y' } }, + { id: `kurt-${stamp}`, type: 'Author', data: { name: 'Kurt' }, private: true }, +] +r = await call('GET', '/versions/latest/manifest') +expect(r.status === 200, 'manifest') +type Line = { id: string; type: string; hash: string; private?: boolean } +const keyOf = (x: { type: string; id: string }) => JSON.stringify([x.type, x.id]) +const have = new Map((r.json.records as Line[]).map((m) => [keyOf(m), m])) +const upserts = snapshot.filter((x) => { + const m = have.get(keyOf(x)) + return m?.hash !== hashRecord(x.id, x.type, x.data).hash || !!m.private !== !!x.private +}) +const keep = new Set(snapshot.map(keyOf)) +const deletes = [...have.values()].filter((m) => !keep.has(keyOf(m))) +expect(upserts.length === 2 && deletes.length === 0, 'diff: Ada changed, Grace new') +r = await call('POST', '/push', { base: r.json.semver }) +sid = r.json.session_id +r = await call('POST', `/push/${sid}/records`, upserts, true) +expect(r.status === 200, 'upload the differences') +r = await call('POST', `/push/${sid}/commit`) +expect(r.status === 201 && r.json.recordCount === 3, 'commit') +console.log('\nsmoke OK') + +export {} diff --git a/packages/server/src/api/access.ts b/packages/server/src/api/access.ts new file mode 100644 index 0000000..9c16874 --- /dev/null +++ b/packages/server/src/api/access.ts @@ -0,0 +1,168 @@ +/** + * Who is calling, and what they may do with a collection. + * + * Authorization happens once per request: the collection's visibility and the + * caller's membership pick which sets the caller may read (edge-redesign.md, + * Read path). Nothing below filters individual records. + */ +import { and, eq } from 'drizzle-orm' +import type { Context } from 'hono' + +import type { AppEnv } from '../app.js' +import * as schema from '../db/schema.js' +import type { Db } from '../ports.js' + +export interface Principal { + userId: string + /** 'session' for a signed-in browser; otherwise the API key's scope. */ + scope: 'session' | 'read' | 'write' | 'admin' + /** Collections an API key is limited to; null when unscoped. */ + collectionIds: string[] | null + /** Set for an API key owned by an organization: it acts as a member of that org only. */ + orgId?: string + /** The signed-in session's id (scope 'session' only). */ + sessionId?: string +} + +/** + * The role a caller acts with: a key's scope caps its holder's role. Sessions + * and admin keys keep the full role; read and write keys act as members, so + * they can't change visibility, delete, or manage mirrors, webhooks or the org. + * A key confined to some collections acts as a member too, whatever its scope: + * it may write to them, never manage them (see keyCannotManage). + */ +export function capRole(p: Principal, role: string | null): string | null { + if (!role) return null + if (p.collectionIds) return 'member' + return p.scope === 'session' || p.scope === 'admin' ? role : 'member' +} + +/** + * 403 for a key confined to some collections, on a route that manages a + * collection (visibility, deletion, transfer, webhooks, placements); null for + * anyone else. capRole already keeps such a key from an admin's role; this + * says why. + */ +export function keyCannotManage(c: Context): Response | null { + return c.var.principal?.collectionIds + ? jsonError(c, 403, 'This key cannot manage collections') + : null +} + +export interface CollectionAccess { + collection: typeof schema.collections.$inferSelect + owner: typeof schema.organization.$inferSelect + /** Members of the owning org read both sets. */ + isMember: boolean + /** + * The member's role in the owning org ('owner' | 'admin' | 'member'), when a + * member, capped by the key's scope (capRole). + */ + role: string | null + canRead: boolean + canWrite: boolean + /** Which sets this caller may read. */ + sets: ('public' | 'private')[] +} + +/** The id form of a collection URL, `/_/` (spec 11.3.1); `_` is never a slug. */ +export const ID_OWNER = '_' + +/** + * Which collection `/` names (spec 11.3.1): `_` and a collection id, + * or an owner handle and the collection's slug. Owners are named here by their + * hosted handle, the org slug. A DID (`did:…`) or a domain handle (a dot) names + * no org yet: no org has one, so either is a 404. + */ +function collectionWhere(owner: string, slug: string) { + if (owner === ID_OWNER) return eq(schema.collections.id, slug) + if (owner.startsWith('did:') || owner.includes('.')) return null + return and(eq(schema.organization.slug, owner), eq(schema.collections.slug, slug)) +} + +export async function collectionAccess( + db: Db, + principal: Principal | null, + owner: string, + slug: string, +): Promise { + const where = collectionWhere(owner, slug) + if (!where) return null + const [row] = await db + .select({ collection: schema.collections, owner: schema.organization }) + .from(schema.collections) + .innerJoin(schema.organization, eq(schema.organization.id, schema.collections.organizationId)) + .where(where) + .limit(1) + if (!row || row.collection.deletedAt) return null + + let isMember = false + let role: string | null = null + if (principal) { + const keyCovers = + principal.collectionIds === null || principal.collectionIds.includes(row.collection.id) + if (keyCovers && principal.orgId) { + isMember = principal.orgId === row.owner.id + // An org-owned key acts with the org's authority, as far as its scope goes. + if (isMember) role = capRole(principal, 'owner') + } else if (keyCovers) { + const [m] = await db + .select({ id: schema.member.id, role: schema.member.role }) + .from(schema.member) + .where( + and( + eq(schema.member.organizationId, row.owner.id), + eq(schema.member.userId, principal.userId), + ), + ) + .limit(1) + isMember = !!m + role = capRole(principal, m?.role ?? null) + } + } + const canRead = row.collection.public || isMember + const canWrite = isMember && principal !== null && principal.scope !== 'read' + return { + ...row, + isMember, + role, + canRead, + canWrite, + sets: isMember ? ['public', 'private'] : ['public'], + } +} + +export const jsonError = ( + c: Context, + status: 400 | 401 | 403 | 404 | 409 | 413 | 422 | 429 | 451 | 500 | 503, + error: string, + extra: Record = {}, +) => c.json({ error, statusCode: status, ...extra }, status) + +/** + * Resolve `:owner/:slug` for a request: an owner handle and slug, or `_` and a + * collection id. A collection the caller can't read is a 404, so private + * collections don't leak their existence. + */ +export async function requireCollection( + c: Context, + need: 'read' | 'write', +): Promise { + const owner = c.req.param('owner') ?? '' + const slug = c.req.param('slug') ?? '' + const resolve = () => collectionAccess(c.var.ports.db, c.var.principal, owner, slug) + // A page's in-process calls resolve each collection once (app.ts PageContext). + const memo = c.var.page?.access + const key = `${owner}/${slug}` + if (memo && !memo.has(key)) memo.set(key, resolve()) + const access = (await (memo?.get(key) ?? resolve())) as CollectionAccess | null + if (!access || !access.canRead) return jsonError(c, 404, 'Collection not found') + if (need === 'write' && !access.canWrite) { + return c.var.principal + ? jsonError(c, 403, 'Not authorized') + : jsonError(c, 401, 'Authentication required') + } + // Usage on this request is billed to the collection's owner (billing/usage.ts). + c.var.meter.collection = { id: access.collection.id, accountId: access.owner.id } + return access +} diff --git a/packages/server/src/api/accounts.ts b/packages/server/src/api/accounts.ts new file mode 100644 index 0000000..51432e9 --- /dev/null +++ b/packages/server/src/api/accounts.ts @@ -0,0 +1,334 @@ +/** + * The signed-in user's account and the orgs they own (v1's `src/api/accounts.ts` + * shapes, which the UI calls): + * + * GET /api/accounts/me profile and memberships + * PATCH /api/accounts/me {slug?, displayName?, bio?, website?} + * DELETE /api/accounts/me {confirmSlug} + * GET /api/accounts/me/sessions signed-in devices + * DELETE /api/accounts/me/sessions/:id + * GET /api/accounts/available-kf-orgs KF orgs a new org may link to + * POST /api/accounts/invitations/accept {token}: an invitation id + * PATCH /api/accounts/:slug org profile (owners: session or admin key) + * DELETE /api/accounts/:slug an org (owners: session or admin key) + * + * Sessions, invitation acceptance and the guards on deletion are new in v2; + * the UI called the first two, but v1 never served them. + * + * Deleting an org cascades to its collections in this schema, so an org (or a + * personal account) that still holds collections is refused: delete or + * transfer them first. + */ +import { and, count, eq, isNull } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import * as schema from '../db/schema.js' +import { deleteOrgAvatars } from '../lib/avatars.js' +import { validateOrgSlug } from '../lib/slug.js' +import { capRole, jsonError } from './access.js' + +/** + * The calling user, for account routes: a session or an unscoped personal key + * (write routes refuse read keys). Collection-scoped and org-owned keys are + * not a user, as in v1's requireUnscopedKey. + */ +export function requireUser(c: Context, write: boolean): string | Response { + const p = c.var.principal + if (!p) return jsonError(c, 401, 'Authentication required') + if (p.collectionIds || p.orgId) return jsonError(c, 403, 'This key cannot manage accounts') + if (write && p.scope === 'read') return jsonError(c, 403, 'Read-only key') + return p.userId +} + +export async function orgBySlug(c: Context, slug: string) { + const [org] = await c.var.ports.db + .select() + .from(schema.organization) + .where(eq(schema.organization.slug, slug)) + .limit(1) + return org ?? null +} + +export async function roleIn(c: Context, orgId: string, userId: string) { + const [m] = await c.var.ports.db + .select({ role: schema.member.role }) + .from(schema.member) + .where(and(eq(schema.member.organizationId, orgId), eq(schema.member.userId, userId))) + .limit(1) + return m?.role ?? null +} + +async function personalOrg(c: Context, userId: string) { + const [row] = await c.var.ports.db + .select({ org: schema.organization }) + .from(schema.member) + .innerJoin(schema.organization, eq(schema.organization.id, schema.member.organizationId)) + .where(and(eq(schema.member.userId, userId), eq(schema.organization.isDefault, true))) + .limit(1) + return row?.org ?? null +} + +/** Collections an org still holds (soft-deleted ones excluded). */ +async function collectionCount(c: Context, orgId: string) { + const [row] = await c.var.ports.db + .select({ n: count() }) + .from(schema.collections) + .where(and(eq(schema.collections.organizationId, orgId), isNull(schema.collections.deletedAt))) + return row?.n ?? 0 +} + +/** 422/409 for a slug change, or null when `slug` is free for org `orgId`. */ +async function slugProblem(c: Context, slug: unknown, orgId: string) { + const err = validateOrgSlug(slug) + if (err) return jsonError(c, 422, err) + const taken = await orgBySlug(c, slug as string) + if (taken && taken.id !== orgId) return jsonError(c, 409, 'That slug is already taken') + return null +} + +const str = (v: unknown) => (typeof v === 'string' ? v : undefined) +const strOrNull = (v: unknown) => (v === null ? null : str(v)) + +export function accountRoutes() { + const app = new Hono() + + app.get('/api/accounts/me', async (c) => { + const userId = requireUser(c, false) + if (userId instanceof Response) return userId + const { db } = c.var.ports + const [u] = await db.select().from(schema.user).where(eq(schema.user.id, userId)).limit(1) + if (!u) return jsonError(c, 404, 'User not found') + const orgs = await db + .select({ + organizationId: schema.member.organizationId, + role: schema.member.role, + slug: schema.organization.slug, + name: schema.organization.name, + isDefault: schema.organization.isDefault, + }) + .from(schema.member) + .innerJoin(schema.organization, eq(schema.organization.id, schema.member.organizationId)) + .where(eq(schema.member.userId, u.id)) + const own = orgs.find((o) => o.isDefault) + return c.json({ + id: u.id, + name: u.name, + email: u.email, + image: u.image, + slug: own?.slug ?? null, + displayName: own?.name ?? u.name, + createdAt: u.createdAt, + orgs, + }) + }) + + app.patch('/api/accounts/me', async (c) => { + const userId = requireUser(c, true) + if (userId instanceof Response) return userId + const body = ((await c.req.json().catch(() => null)) ?? {}) as Record + const org = await personalOrg(c, userId) + if (!org) return jsonError(c, 404, 'Default org not found') + if (body.slug !== undefined) { + const bad = await slugProblem(c, body.slug, org.id) + if (bad) return bad + } + // notificationPrefs (sent by the settings page) has nowhere to live yet; as v1, ignored. + const set: Partial = {} + if (str(body.slug) !== undefined) set.slug = str(body.slug)! + if (str(body.displayName) !== undefined) set.name = str(body.displayName)! + if (strOrNull(body.bio) !== undefined) set.bio = strOrNull(body.bio)! + if (strOrNull(body.website) !== undefined) set.website = strOrNull(body.website)! + if (Object.keys(set).length) { + await c.var.ports.db + .update(schema.organization) + .set(set) + .where(eq(schema.organization.id, org.id)) + // Each collection.json names its owner by slug and name (spec 11.1). + if (set.slug !== undefined || set.name !== undefined) + await c.var.ports.jobs.enqueue({ type: 'collection.info.org', organizationId: org.id }) + } + return c.json({ ok: true, slug: set.slug ?? org.slug }) + }) + + app.delete('/api/accounts/me', async (c) => { + const userId = requireUser(c, true) + if (userId instanceof Response) return userId + // Deleting the account takes a session or an admin key, as org deletion does (capRole). + if (c.var.principal?.scope === 'read' || c.var.principal?.scope === 'write') + return jsonError(c, 403, 'Deleting an account needs a session or an admin key') + const body = (await c.req.json().catch(() => null)) as { confirmSlug?: unknown } | null + const org = await personalOrg(c, userId) + if (!org) return jsonError(c, 404, 'Account not found') + if (body?.confirmSlug !== org.slug) { + return jsonError(c, 422, 'Username confirmation does not match') + } + const held = await collectionCount(c, org.id) + if (held) { + return jsonError( + c, + 409, + `Your account still holds ${held} collection${held === 1 ? '' : 's'}. Delete or transfer them first.`, + ) + } + // Members, sessions, accounts and sent invitations cascade from the user row. + const { db } = c.var.ports + await db.batch([ + db.delete(schema.apikey).where(eq(schema.apikey.referenceId, userId)), + db.delete(schema.apikey).where(eq(schema.apikey.referenceId, org.id)), + db.delete(schema.organization).where(eq(schema.organization.id, org.id)), + db.delete(schema.user).where(eq(schema.user.id, userId)), + ]) + await deleteOrgAvatars(c.var.ports.publicAssets, org.id) + return c.json({ ok: true }) + }) + + app.get('/api/accounts/me/sessions', async (c) => { + const p = c.var.principal + if (!p || p.scope !== 'session') return jsonError(c, 401, 'Sign in to see your sessions') + const rows = await c.var.ports.db + .select({ + id: schema.session.id, + userAgent: schema.session.userAgent, + ipAddress: schema.session.ipAddress, + createdAt: schema.session.createdAt, + expiresAt: schema.session.expiresAt, + }) + .from(schema.session) + .where(eq(schema.session.userId, p.userId)) + const now = Date.now() + return c.json( + rows + .filter((s) => s.expiresAt.getTime() > now) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .map((s) => ({ ...s, current: s.id === p.sessionId })), + ) + }) + + app.delete('/api/accounts/me/sessions/:id', async (c) => { + const p = c.var.principal + if (!p || p.scope !== 'session') return jsonError(c, 401, 'Sign in to manage your sessions') + const gone = await c.var.ports.db + .delete(schema.session) + .where(and(eq(schema.session.id, c.req.param('id')), eq(schema.session.userId, p.userId))) + .returning({ id: schema.session.id }) + if (!gone.length) return jsonError(c, 404, 'Session not found') + return c.json({ ok: true }) + }) + + app.get('/api/accounts/available-kf-orgs', async (c) => { + const userId = requireUser(c, false) + if (userId instanceof Response) return userId + return c.json((await c.var.kf?.orgs(userId)) ?? []) + }) + + app.post('/api/accounts/invitations/accept', async (c) => { + const userId = requireUser(c, true) + if (userId instanceof Response) return userId + const body = (await c.req.json().catch(() => null)) as { token?: unknown } | null + const token = str(body?.token) + if (!token) return jsonError(c, 400, 'token is required') + const { db } = c.var.ports + const [inv] = await db + .select({ invitation: schema.invitation, orgSlug: schema.organization.slug }) + .from(schema.invitation) + .innerJoin(schema.organization, eq(schema.organization.id, schema.invitation.organizationId)) + .where(eq(schema.invitation.id, token)) + .limit(1) + const [u] = await db.select().from(schema.user).where(eq(schema.user.id, userId)).limit(1) + // One answer for missing, used, expired and someone else's: the token reveals nothing. + if ( + !inv || + !u || + inv.invitation.status !== 'pending' || + inv.invitation.expiresAt.getTime() < Date.now() || + inv.invitation.email.trim().toLowerCase() !== u.email.trim().toLowerCase() + ) { + return jsonError(c, 404, 'Invitation is invalid or expired.') + } + const existing = await roleIn(c, inv.invitation.organizationId, userId) + await db.batch([ + db + .update(schema.invitation) + .set({ status: 'accepted' }) + .where(eq(schema.invitation.id, inv.invitation.id)), + ...(existing + ? [] + : [ + db.insert(schema.member).values({ + organizationId: inv.invitation.organizationId, + userId, + role: inv.invitation.role ?? 'member', + }), + ]), + ]) + return c.json({ ok: true, orgSlug: inv.orgSlug }) + }) + + app.patch('/api/accounts/:slug', async (c) => { + const userId = requireUser(c, true) + if (userId instanceof Response) return userId + const org = await orgBySlug(c, c.req.param('slug')) + if (!org) return jsonError(c, 404, 'Organization not found') + // An owner acting with a session or an admin key: a write key acts as a member. + if (capRole(c.var.principal!, await roleIn(c, org.id, userId)) !== 'owner') { + return jsonError(c, 403, 'Must be an owner to update the organization') + } + const body = ((await c.req.json().catch(() => null)) ?? {}) as Record + if (body.slug !== undefined) { + const bad = await slugProblem(c, body.slug, org.id) + if (bad) return bad + } + // /api/kf/summary trusts kfOrgId, so a caller may set only one of their own KF orgs. + const kfOrgId = strOrNull(body.kfOrgId) + if (kfOrgId && !(await c.var.kf?.entitled(userId, kfOrgId))) { + return jsonError(c, 403, 'You are not a member of that KF organization') + } + const set: Partial = {} + if (str(body.slug) !== undefined) set.slug = str(body.slug)! + if (str(body.displayName) !== undefined) set.name = str(body.displayName)! + if (strOrNull(body.bio) !== undefined) set.bio = strOrNull(body.bio)! + if (strOrNull(body.website) !== undefined) set.website = strOrNull(body.website)! + if (kfOrgId !== undefined) set.kfOrgId = kfOrgId || null + if (Object.keys(set).length) { + await c.var.ports.db + .update(schema.organization) + .set(set) + .where(eq(schema.organization.id, org.id)) + // Each collection.json names its owner by slug and name (spec 11.1). + if (set.slug !== undefined || set.name !== undefined) + await c.var.ports.jobs.enqueue({ type: 'collection.info.org', organizationId: org.id }) + } + return c.json({ ok: true, slug: set.slug ?? org.slug }) + }) + + app.delete('/api/accounts/:slug', async (c) => { + const userId = requireUser(c, true) + if (userId instanceof Response) return userId + const org = await orgBySlug(c, c.req.param('slug')) + if (!org) return jsonError(c, 404, 'Organization not found') + // An owner acting with a session or an admin key: a write key acts as a member. + if (capRole(c.var.principal!, await roleIn(c, org.id, userId)) !== 'owner') { + return jsonError(c, 403, 'Must be an owner to delete the organization') + } + if (org.isDefault) return jsonError(c, 409, 'A personal account is deleted from its settings') + const held = await collectionCount(c, org.id) + if (held) { + return jsonError( + c, + 409, + `This organization still holds ${held} collection${held === 1 ? '' : 's'}. Delete or transfer them first.`, + ) + } + // Members, invitations, locations and placements cascade from the org row. + const { db } = c.var.ports + await db.batch([ + db.delete(schema.apikey).where(eq(schema.apikey.referenceId, org.id)), + db.delete(schema.organization).where(eq(schema.organization.id, org.id)), + ]) + await deleteOrgAvatars(c.var.ports.publicAssets, org.id) + return c.json({ ok: true }) + }) + + return app +} diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts new file mode 100644 index 0000000..aeae199 --- /dev/null +++ b/packages/server/src/api/admin.ts @@ -0,0 +1,404 @@ +/** + * Steward pages and the KF dashboard summary (v1's `server.ts` explore routes + * and `src/api/kf-summary.ts`): + * + * GET|PUT /api/admin/explore-tags {tags: string[]} stewards + * GET|PUT /api/admin/explore-collections {collections: "owner/slug"[]} stewards + * GET /api/kf/summary?kf_org_id= KF Auth, with the internal API key + * POST /api/abuse-reports {hash?, url?, reason, contact?}: anyone + * GET /api/admin/abuse-reports stewards: ?status=open|blocked|dismissed + * PATCH /api/admin/abuse-reports/:id stewards: {status: 'dismissed'|'open'} + * GET /api/admin/denylist stewards + * POST /api/admin/denylist stewards: {hash, kind, reason, reportId?} + * DELETE /api/admin/denylist/:hash stewards + * POST /api/admin/reconcile stewards: {collection: "owner/slug"} starts a run + * GET /api/admin/reconcile?collection= stewards: the last run's time and report + * GET /api/admin/usage?day=&account= stewards: a day's usage rollups + * POST /api/admin/fsck stewards: {collection, fileBytes?} checks its repository + * GET /api/admin/fsck?collection= stewards: the last check's report + * POST /api/admin/usage/rebuild stewards: {day} recomputes them from the usage log + * + * A steward is a KF Auth user whose role is 'admin', read fresh on each check. + */ +import { and, count, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import { usageFor } from '../billing/usage.js' +import { chunks } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import { forgetDenylist } from '../lib/limits.js' +import { jsonError } from './access.js' + +/** The signed-in person (session or unscoped key), as v1's requireAuth + requireUnscopedKey. */ +function person(c: Context, write: boolean): string | null { + const p = c.var.principal + if (!p || p.collectionIds || p.orgId) return null + if (write && p.scope === 'read') return null + return p.userId +} + +async function isSteward(c: Context, userId: string | null): Promise { + return !!userId && (await c.var.kf?.role(userId)) === 'admin' +} + +/** 401/403 unless the caller is a steward (with a write key, for a change); null when they are. */ +export async function stewardOnly(c: Context): Promise { + const userId = person(c, c.req.method !== 'GET' && c.req.method !== 'HEAD') + if (!userId) return jsonError(c, 401, 'Unauthorized') + return (await isSteward(c, userId)) ? null : jsonError(c, 403, 'Forbidden') +} + +const isStrings = (v: unknown): v is string[] => + Array.isArray(v) && v.every((s) => typeof s === 'string') + +async function readSetting(c: Context, key: string): Promise { + const [row] = await c.var.ports.db + .select({ value: schema.instanceSettings.value }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.key, key)) + .limit(1) + return row?.value +} + +async function writeSetting(c: Context, key: string, value: unknown) { + await c.var.ports.db + .insert(schema.instanceSettings) + .values({ key, value, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: schema.instanceSettings.key, + set: { value, updatedAt: new Date() }, + }) +} + +const str = (v: unknown, max: number) => (typeof v === 'string' && v.length <= max ? v : undefined) + +export function adminRoutes() { + const app = new Hono() + + for (const [path, key, field, hint] of [ + [ + '/api/admin/explore-tags', + 'explore_featured_tags', + 'tags', + 'tags must be an array of strings', + ], + [ + '/api/admin/explore-collections', + 'explore_featured_collections', + 'collections', + 'collections must be an array of "owner/slug" strings', + ], + ] as const) { + app.get(path, async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const value = await readSetting(c, key) + return c.json({ [field]: isStrings(value) ? value : [] }) + }) + app.put(path, async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const body = (await c.req.json().catch(() => null)) as Record | null + const value = body?.[field] + if (!isStrings(value)) return jsonError(c, 422, hint) + await writeSetting(c, key, value) + return c.json({ ok: true, [field]: value }) + }) + } + + app.get('/api/kf/summary', async (c) => { + const kfOrgId = c.req.query('kf_org_id') + if (!kfOrgId) return jsonError(c, 400, 'kf_org_id is required') + if (!c.var.kf?.isInternalCall(c.req.header('authorization'))) { + return jsonError(c, 401, 'Unauthorized') + } + const { db } = c.var.ports + const appUrl = c.var.config.appUrl + const orgs = await db + .select({ + id: schema.organization.id, + slug: schema.organization.slug, + name: schema.organization.name, + }) + .from(schema.organization) + .where(eq(schema.organization.kfOrgId, kfOrgId)) + if (!orgs.length) return c.json({ orgs: [] }) + const cols: { + id: string + slug: string + name: string + organizationId: string + }[] = [] + for (const part of chunks(orgs.map((o) => o.id))) { + cols.push( + ...(await db + .select({ + id: schema.collections.id, + slug: schema.collections.slug, + name: schema.collections.name, + organizationId: schema.collections.organizationId, + }) + .from(schema.collections) + .where( + and( + inArray(schema.collections.organizationId, part), + isNull(schema.collections.deletedAt), + ), + )), + ) + } + // As v1: sums over the collection's versions (every v2 row is a published one). + const stats = new Map< + string, + { versions: number; records: number; files: number; bytes: number } + >() + const v = schema.versions + for (const part of chunks(cols.map((x) => x.id))) { + const rows = await db + .select({ + collectionId: v.collectionId, + versions: count(), + records: sql`coalesce(sum(${v.recordCount}), 0)`, + files: sql`coalesce(sum(${v.fileCount}), 0)`, + bytes: sql`coalesce(sum(${v.totalBytes}), 0)`, + }) + .from(v) + .where(inArray(v.collectionId, part)) + .groupBy(v.collectionId) + for (const r of rows) { + stats.set(r.collectionId, { + versions: r.versions, + records: Number(r.records), + files: Number(r.files), + bytes: Number(r.bytes), + }) + } + } + return c.json({ + orgs: orgs.map((o) => ({ + id: o.id, + slug: o.slug, + name: o.name ?? o.slug, + url: `${appUrl}/${o.slug}`, + collections: cols + .filter((x) => x.organizationId === o.id) + .map((x) => ({ + id: x.id, + name: x.name, + slug: x.slug, + url: `${appUrl}/${o.slug}/${x.slug}`, + stats: stats.get(x.id) ?? { versions: 0, records: 0, files: 0, bytes: 0 }, + })), + })), + }) + }) + + const collectionBySlugs = async (c: Context, ref: unknown) => { + const [owner, slug] = typeof ref === 'string' ? ref.split('/') : [] + if (!owner || !slug) return null + const [row] = await c.var.ports.db + .select({ c: schema.collections }) + .from(schema.collections) + .innerJoin(schema.organization, eq(schema.organization.id, schema.collections.organizationId)) + .where(and(eq(schema.organization.slug, owner), eq(schema.collections.slug, slug))) + return row?.c ?? null + } + + app.post('/api/admin/reconcile', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const b = (await c.req.json().catch(() => null)) as { collection?: unknown } | null + const col = await collectionBySlugs(c, b?.collection) + if (!col) return jsonError(c, 404, 'Collection not found (send {"collection": "owner/slug"})') + // A steward's run checks every version again, not only new ones. + await c.var.ports.jobs.enqueue({ + type: 'reconcile.collection', + collectionId: col.id, + full: true, + }) + return c.json({ ok: true, queued: true }, 202) + }) + + app.get('/api/admin/reconcile', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const col = await collectionBySlugs(c, c.req.query('collection')) + if (!col) return jsonError(c, 404, 'Collection not found (?collection=owner/slug)') + return c.json({ + startedAt: col.reconcileStartedAt, + reconciledAt: col.reconciledAt, + running: + !!col.reconcileStartedAt && + !(col.reconciledAt && col.reconciledAt >= col.reconcileStartedAt), + report: col.reconcileReport ?? [], + }) + }) + + app.post('/api/admin/fsck', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const b = (await c.req.json().catch(() => null)) as { + collection?: unknown + fileBytes?: unknown + } | null + const col = await collectionBySlugs(c, b?.collection) + if (!col) return jsonError(c, 404, 'Collection not found (send {"collection": "owner/slug"})') + await c.var.ports.jobs.enqueue({ + type: 'repo.fsck', + collectionId: col.id, + fileBytes: b?.fileBytes === true, + }) + return c.json({ ok: true, queued: true }, 202) + }) + + app.get('/api/admin/fsck', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const col = await collectionBySlugs(c, c.req.query('collection')) + if (!col) return jsonError(c, 404, 'Collection not found (?collection=owner/slug)') + const obj = await c.var.ports.stores.internal.get(`fsck/${col.id}.json`) + if (!obj) return jsonError(c, 404, 'Not checked yet') + return c.json(JSON.parse(await obj.text())) + }) + + const DAY = /^\d{4}-\d{2}-\d{2}$/ + app.get('/api/admin/usage', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const day = c.req.query('day') ?? '' + if (!DAY.test(day)) return jsonError(c, 400, 'day must be YYYY-MM-DD') + return c.json({ day, rollups: await usageFor(c.var.ports, day, c.req.query('account')) }) + }) + + app.post('/api/admin/usage/rebuild', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const b = (await c.req.json().catch(() => null)) as { day?: unknown } | null + if (typeof b?.day !== 'string' || !DAY.test(b.day)) + return jsonError(c, 400, 'day must be YYYY-MM-DD') + await c.var.ports.jobs.enqueue({ type: 'usage.rebuild', day: b.day }) + return c.json({ ok: true, queued: true }, 202) + }) + + app.post('/api/abuse-reports', async (c) => { + const b = (await c.req.json().catch(() => null)) as Record | null + const hash = b?.hash === undefined || b.hash === '' ? null : str(b.hash, 80) + const url = b?.url === undefined || b.url === '' ? null : str(b.url, 2000) + const reason = str(b?.reason, 4000)?.trim() + const contact = b?.contact === undefined || b.contact === '' ? null : str(b.contact, 320) + if (hash === undefined || url === undefined || contact === undefined || !reason) { + return jsonError(c, 400, 'A reason is required; hash, url and contact are optional strings') + } + if (!hash && !url) return jsonError(c, 400, 'Name the content: a hash or a url') + const cleanHash = hash?.replace(/^sha256:/, '') ?? null + if (cleanHash && !/^[0-9a-f]{64}$/.test(cleanHash)) { + return jsonError(c, 400, 'hash must be 64 hex characters (a file or record hash)') + } + const [report] = await c.var.ports.db + .insert(schema.abuseReports) + .values({ + hash: cleanHash, + url, + reason, + contact, + reporterId: c.var.principal?.userId ?? null, + }) + .returning({ id: schema.abuseReports.id }) + return c.json({ ok: true, id: report!.id }, 201) + }) + + app.get('/api/admin/abuse-reports', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const status = (['open', 'blocked', 'dismissed'] as const).find( + (s) => s === c.req.query('status'), + ) + const t = schema.abuseReports + const reports = await c.var.ports.db + .select() + .from(t) + .where(eq(t.status, status ?? 'open')) + .orderBy(desc(t.createdAt)) + .limit(200) + return c.json({ reports }) + }) + + app.patch('/api/admin/abuse-reports/:id', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const b = (await c.req.json().catch(() => null)) as { status?: unknown } | null + const status = (['open', 'dismissed'] as const).find((s) => s === b?.status) + if (!status) + return jsonError(c, 400, 'status must be "dismissed" or "open" (block through the denylist)') + const t = schema.abuseReports + const rows = await c.var.ports.db + .update(t) + .set({ + status, + resolvedBy: status === 'open' ? null : c.var.principal!.userId, + resolvedAt: status === 'open' ? null : new Date(), + }) + .where(eq(t.id, c.req.param('id'))) + .returning({ id: t.id }) + if (!rows.length) return jsonError(c, 404, 'Report not found') + return c.json({ ok: true }) + }) + + app.get('/api/admin/denylist', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const entries = await c.var.ports.db + .select() + .from(schema.denylist) + .orderBy(desc(schema.denylist.createdAt)) + return c.json({ entries }) + }) + + app.post('/api/admin/denylist', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const b = (await c.req.json().catch(() => null)) as Record | null + const hash = str(b?.hash, 80)?.replace(/^sha256:/, '') + const kind = (['file', 'record'] as const).find((k) => k === b?.kind) + const reason = str(b?.reason, 4000)?.trim() + if (!hash || !/^[0-9a-f]{64}$/.test(hash) || !kind || !reason) { + return jsonError(c, 400, 'hash (64 hex), kind ("file" or "record") and reason are required') + } + const { db } = c.var.ports + const userId = c.var.principal!.userId + await db + .insert(schema.denylist) + .values({ hash, kind, reason }) + .onConflictDoUpdate({ target: schema.denylist.hash, set: { kind, reason } }) + // Every open report on this hash, and the one named, is resolved by the block. + const reportId = str(b?.reportId, 64) + const t = schema.abuseReports + await db + .update(t) + .set({ status: 'blocked', resolvedBy: userId, resolvedAt: new Date() }) + .where( + and( + eq(t.status, 'open'), + reportId ? or(eq(t.hash, hash), eq(t.id, reportId)) : eq(t.hash, hash), + ), + ) + forgetDenylist(db) + return c.json({ ok: true }, 201) + }) + + app.delete('/api/admin/denylist/:hash', async (c) => { + const denied = await stewardOnly(c) + if (denied) return denied + const { db } = c.var.ports + const rows = await db + .delete(schema.denylist) + .where(eq(schema.denylist.hash, c.req.param('hash').replace(/^sha256:/, ''))) + .returning({ hash: schema.denylist.hash }) + if (!rows.length) return jsonError(c, 404, 'Not on the denylist') + forgetDenylist(db) + return c.json({ ok: true }) + }) + + return app +} diff --git a/packages/server/src/api/agent.ts b/packages/server/src/api/agent.ts new file mode 100644 index 0000000..991d368 --- /dev/null +++ b/packages/server/src/api/agent.ts @@ -0,0 +1,330 @@ +/** + * Agent links (v1's `src/api/agent.ts`, for v2's delta push): + * + * GET /agent/:token an HTML page that tells an AI agent how to push to one collection + * + * The token is an API key the share panel (web's share-panel.tsx) creates: a + * write key confined to one collection, with `agentShare: true` in its + * metadata, expiring in an hour. The page is the key's instructions; the key + * itself is what authorizes the pushes, through the usual API. + * + * A token that isn't such a key, has expired or been revoked, or whose holder + * can no longer write to the collection is a 404 page. A path segment that + * isn't key-shaped (`ul_…`) falls through to the UI: collection slugs have no + * underscore, so an org named "agent" keeps its collection pages. + */ +import { and, eq } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import * as schema from '../db/schema.js' +import { findVersion, loadView, typeRecords } from '../versions/view.js' +import { collectionAccess, type Principal } from './access.js' + +const DEFAULT_SCHEMA_SLUG = 'update' +const DEFAULT_SCHEMA = { + type: 'object', + properties: { + title: { type: 'string' }, + summary: { type: 'string' }, + key_points: { type: 'array', items: { type: 'string' } }, + source: { type: 'string' }, + timestamp: { type: 'string' }, + }, + required: ['title', 'summary'], + additionalProperties: false, +} + +/** Example records shown, and the largest record body shown as one. */ +const EXAMPLES = 3 +const MAX_EXAMPLE_BYTES = 4 * 1024 + +const KEY_SHAPE = /^ul_[A-Za-z0-9]{8,256}$/ + +const escapeHtml = (s: string) => + s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +/** better-auth stores JSON columns as strings, older rows twice over (auth/auth.ts). */ +function jsonColumn(v: unknown): T | null { + let out = v + for (let i = 0; i < 2 && typeof out === 'string'; i++) { + try { + out = JSON.parse(out) + } catch { + return null + } + } + return (out as T) ?? null +} + +const b64url = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + +/** + * The agent key `token`, if it is one: a live write key confined to exactly one + * collection and made for an agent link. Read from its row as auth/auth.ts + * verifyKey does (better-auth's hash of the key), without counting a use. + */ +async function agentKey(c: Context, token: string) { + const hashed = b64url( + new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))), + ) + const { db } = c.var.ports + const [row] = await db + .select() + .from(schema.apikey) + .where(and(eq(schema.apikey.key, hashed), eq(schema.apikey.configId, 'default'))) + .limit(1) + if (!row || row.enabled === false) return null + if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null + if (row.remaining !== null && row.remaining <= 0) return null + const perms = jsonColumn>(row.permissions)?.collections ?? [] + if (!perms.includes('write')) return null + const meta = jsonColumn<{ agentShare?: unknown; collectionIds?: unknown }>(row.metadata) + const ids = meta?.collectionIds + if (meta?.agentShare !== true || !Array.isArray(ids) || ids.length !== 1) return null + if (typeof ids[0] !== 'string') return null + const [org] = await db + .select({ id: schema.organization.id }) + .from(schema.organization) + .where(eq(schema.organization.id, row.referenceId)) + .limit(1) + const principal: Principal = { + userId: row.referenceId, + scope: perms.includes('admin') ? 'admin' : 'write', + collectionIds: [ids[0]], + ...(org ? { orgId: org.id } : {}), + } + return { principal, collectionId: ids[0], expiresAt: row.expiresAt } +} + +/** Every agent page: no scripts, no sniffing, no indexing; never cached (it holds a key). */ +const HEADERS = { + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'", + 'X-Content-Type-Options': 'nosniff', + 'X-Robots-Tag': 'noindex', +} + +const invalid = (c: Context) => + c.html( + ` +Invalid agent link +

Invalid or expired agent link

+

This agent link is no longer valid. Ask the collection's owner for a new one.

+`, + 404, + HEADERS, + ) + +const json = (v: unknown) => escapeHtml(JSON.stringify(v, null, 2)) + +export function agentRoutes() { + const app = new Hono() + + app.get('/agent/:token', async (c, next) => { + const token = c.req.param('token') + if (!token.startsWith('ul_')) return next() + if (!KEY_SHAPE.test(token)) return invalid(c) + const key = await agentKey(c, token) + if (!key) return invalid(c) + const { db } = c.var.ports + const [where] = await db + .select({ owner: schema.organization.slug, slug: schema.collections.slug }) + .from(schema.collections) + .innerJoin(schema.organization, eq(schema.organization.id, schema.collections.organizationId)) + .where(eq(schema.collections.id, key.collectionId)) + .limit(1) + if (!where) return invalid(c) + // A key's collectionIds are whatever its creator wrote, so the key's holder + // must still be able to write here: this page shows schemas and records. + const access = await collectionAccess(db, key.principal, where.owner, where.slug) + if (!access?.canWrite) return invalid(c) + const coll = access.collection + + const head = await findVersion(db, coll.id, 'latest', coll.headVersionId) + const repo = await c.var.ports.stores.forCollection(coll.id) + const view = head ? await loadView(repo, head, true) : null + const schemas: { slug: string; schema: unknown }[] = view + ? await Promise.all( + view.types.map(async (t) => ({ slug: t.slug, schema: await repo.schema(t.schemaHash) })), + ) + : [] + const examples: { id: string; type: string; data: unknown; private?: true }[] = [] + if (view && view.types[0]) { + for await (const e of typeRecords(view, view.types[0], { bodies: true })) { + if (!e.body || e.body.length > MAX_EXAMPLE_BYTES) continue + const r = JSON.parse(e.body) as { id: string; type: string; data: unknown } + examples.push({ + id: r.id, + type: r.type, + data: r.data, + ...(e.set === 'private' ? { private: true as const } : {}), + }) + if (examples.length === EXAMPLES) break + } + } + const metadata = (view?.root.metadata ?? null) as Record | null + const description = + (typeof metadata?.description === 'string' ? metadata.description : null) ?? + coll.summary?.description ?? + '' + + // The request's origin; the configured one when it names the same host, since + // behind a TLS-terminating proxy the request may say http. + const reqUrl = new URL(c.req.url) + const appUrl = new URL(c.var.config.appUrl) + const origin = reqUrl.host === appUrl.host ? appUrl.origin : reqUrl.origin + const collPath = `${access.owner.slug}/${coll.slug}` + const api = `${origin}/api/collections/${collPath}` + const llmsTxt = `${origin}/llms.txt` + const hasSchemas = schemas.length > 0 + const shown = hasSchemas ? schemas : [{ slug: DEFAULT_SCHEMA_SLUG, schema: DEFAULT_SCHEMA }] + const exampleType = shown[0]!.slug + const exampleProps = + (shown[0]!.schema as { properties?: Record }).properties ?? {} + const exampleData: Record = {} + for (const [k, p] of Object.entries(exampleProps)) { + exampleData[k] = p.type === 'string' ? `your ${k} here` : p.type === 'array' ? [] : null + } + const openBody = { + base: head?.semver ?? null, + message: 'Added an update from a conversation', + ...(hasSchemas ? {} : { schemas: { [DEFAULT_SCHEMA_SLUG]: DEFAULT_SCHEMA } }), + } + const recordLines = [ + { id: 'update-1', type: exampleType, data: exampleData }, + { id: 'update-2', type: exampleType, data: exampleData, private: true }, + ] + .map((r) => JSON.stringify(r)) + .join('\n') + const e = escapeHtml + const expires = key.expiresAt + ? `${e(key.expiresAt.toISOString())}. If requests start returning 401, ask the collection's owner for a new link.` + : 'Never.' + + const html = ` + + + + + +Agent: ${e(collPath)} + + + + +

Agent write access

+

This page grants temporary write access to one Underlay collection. It is written for an AI agent. The token in this page's URL is an API key: send it as a Bearer token on every request below.

+ +

Collection

+
+ + +${description ? `` : ''} + +${head ? `` : ''} + + + + +
Collection${e(collPath)}
Name${e(coll.name)}
Description${e(description)}
Current version${head ? `${e(head.semver)}` : 'None (empty collection)'}
Records${head.recordCount.toLocaleString('en-US')}
API key${e(token)}
Key expires${expires}
Key scopeWrite access to this collection only. Other collections answer 404, and account, organization and collection-management endpoints refuse the key.
Collection page${e(`${origin}/${collPath}`)}
+ +

Schema

+${ + hasSchemas + ? schemas + .map((s) => `

Type ${e(s.slug)}

\n
${json(s.schema)}
`) + .join('\n') + : `

This collection has no types yet. Send a schema when you open the push session; this default is recommended:

+
+Default schema, type ${e(DEFAULT_SCHEMA_SLUG)} +
${json(DEFAULT_SCHEMA)}
+title and summary are required; the other fields are optional. +
` +} +${ + examples.length + ? `

Example records from the current version

\n
${json(examples)}
` + : '

The collection has no records yet: yours will be the first.

' +} + +

How to write

+

Every write is a delta push: open a session against the current version, upload the records you add or change, then commit. Records you don't send are kept as they are. Authenticate every request with the API key above.

+ +
The full reference (record rules, files, deletes, privacy, errors) is ${e(llmsTxt)}. The steps below are all an update needs.
+ +

Step 1: Open a session

+
POST ${e(`${api}/push`)}
+Authorization: Bearer ${e(token)}
+Content-Type: application/json
+
+${json(openBody)}
+ + + + +
baseThe version you are building on: ${head ? e(head.semver) : 'null'} now. Use null for the first push. If someone publishes first, this is a 409 with currentVersion: open the session again with that as base.
schemas${ + hasSchemas + ? 'Leave it out to keep the collection’s types. If you send it, it is the full type set: a type you leave out is removed with all its records.' + : `Required on the first push: a map of type name to JSON Schema, as above.` + }
messageOptional: what this update is.
+

The response carries session_id, used in the next two steps.

+ +

Step 2: Upload records

+
POST ${e(`${api}/push/`)}<session_id>/records
+Authorization: Bearer ${e(token)}
+Content-Type: application/x-ndjson
+
+${e(recordLines)}
+

One JSON object per line with id, type and data; data must match the type’s schema. A record with an existing (type, id) replaces it. "private": true shows the record to members of the owning organization only; leaving it out makes the record public. Any invalid line is a 422 listing the lines to fix, and nothing from that request is stored.

+ +

Step 3: Commit

+
POST ${e(`${api}/push/`)}<session_id>/commit
+Authorization: Bearer ${e(token)}
+

A 201 answers with the new version: ${e(JSON.stringify({ semver: 'v1.1.0', hash: 'ulv2:…', recordCount: 1, fileCount: 0 }))}. A 409 “Version conflict” means someone published after you opened the session: open a new one with the current version as base and upload again.

+ +

Endpoints

+ + + + + + + + +
Current versionGET ${e(`${api}/versions/latest`)} (404 when there is none)
SchemasGET ${e(`${api}/schemas`)}
Open a sessionPOST ${e(`${api}/push`)}
Upload recordsPOST ${e(`${api}/push/`)}<session_id>/records
CommitPOST ${e(`${api}/push/`)}<session_id>/commit
Session statusGET ${e(`${api}/push/`)}<session_id>
Reference${e(llmsTxt)} and ${e(`${origin}/docs`)}
+ + +` + + // The URL and the page carry a write key: keep both out of caches and out + // of the Referer of any link followed from here. + return c.html(html, 200, HEADERS) + }) + + return app +} diff --git a/packages/server/src/api/ark.ts b/packages/server/src/api/ark.ts new file mode 100644 index 0000000..b32021c --- /dev/null +++ b/packages/server/src/api/ark.ts @@ -0,0 +1,607 @@ +/** + * ARKs (v1 shapes). + * + * GET /api/ark/resolve?path=ark:NAAN/… {type:'redirect', url, metadata} | 404 {type:'not_found'} + * GET /api/collections/:owner/:slug/ark {enabled, customUrl, arkUrl, shoulder, arkId} + * PATCH /api/collections/:owner/:slug/ark {enabled?, customUrl?} + * GET /api/collections/:owner/:slug/ark/record-types [{recordType, redirectUrlField}] + * PATCH /api/collections/:owner/:slug/ark/record-types {recordType, redirectUrlField | null} + * PUT /api/collections/:owner/:slug/ark/record-types {recordType, redirectUrlField} + * DELETE /api/collections/:owner/:slug/ark/record-types/:type + * PATCH /api/accounts/:slug/ark {naan | null} + * GET /ark:NAAN/… 302, ?info / ?? ERC text, ?json metadata + * + * Resolution sees what the caller may read: a non-member resolves only public + * collections and their public set, as everywhere else in v2. + */ +import { and, asc, eq, ne, sql } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import * as schema from '../db/schema.js' +import { + buildArkUrl, + buildErc, + collectionToArkId, + DEFAULT_NAAN, + formatErcDate, + nextShoulderCounter, + parseArkPath, +} from '../lib/ark.js' +import { deniedHashes } from '../lib/limits.js' +import type { Db, Ports } from '../ports.js' +import { findVersion, getRecord, loadView, type VersionRow } from '../versions/view.js' +import { + capRole, + type CollectionAccess, + collectionAccess, + jsonError, + type Principal, +} from './access.js' + +// --- Shoulders and collection ARKs (also for wiring ARK fields into other routes) --- + +/** The org's shoulder, if it has one. */ +export async function orgShoulder(db: Db, organizationId: string): Promise { + const [row] = await db + .select({ shoulder: schema.arkShoulders.shoulder }) + .from(schema.arkShoulders) + .where(eq(schema.arkShoulders.organizationId, organizationId)) + .orderBy(asc(schema.arkShoulders.createdAt)) + .limit(1) + return row?.shoulder ?? null +} + +export async function getOrMintShoulder(db: Db, organizationId: string): Promise { + const existing = await orgShoulder(db, organizationId) + if (existing) return existing + + for (let attempt = 0; attempt < 10; attempt++) { + const [countRow] = await db.select({ count: sql`count(*)` }).from(schema.arkShoulders) + const counter = nextShoulderCounter(countRow?.count ?? 0) + const digit = Math.floor(Math.random() * 10).toString() + const shoulder = `ul${counter}${digit}` + // A concurrent mint can take the same shoulder; nothing inserted means retry. + const inserted = await db + .insert(schema.arkShoulders) + .values({ organizationId, shoulder }) + .onConflictDoNothing() + .returning({ shoulder: schema.arkShoulders.shoulder }) + if (inserted[0]) return inserted[0].shoulder + } + throw new Error('Failed to mint ARK shoulder after 10 attempts') +} + +/** Mint a collection's ARK (and its org's shoulder) if it has none. For collection creation. */ +export async function ensureCollectionArk( + db: Db, + collection: { id: string; organizationId: string }, +): Promise { + await getOrMintShoulder(db, collection.organizationId) + await db + .insert(schema.arkCollections) + .values({ collectionId: collection.id, arkId: collectionToArkId(collection.id) }) + .onConflictDoNothing() +} + +/** + * The collection's ARK URL builder (a version's with a semver), or null when the + * collection has no enabled ARK. + */ +export async function collectionArk( + db: Db, + collectionId: string, + owner: { id: string; arkNaan: string | null }, +): Promise<((semver?: string) => string) | null> { + const [row] = await db + .select() + .from(schema.arkCollections) + .where(eq(schema.arkCollections.collectionId, collectionId)) + .limit(1) + if (!row?.enabled) return null + const shoulder = await orgShoulder(db, owner.id) + if (!shoulder) return null + return (semver) => buildArkUrl(owner.arkNaan ?? DEFAULT_NAAN, shoulder, row.arkId, semver) +} + +// --- Resolution --- + +export type ArkResolution = + | { type: 'redirect'; url: string; metadata: Record } + | { type: 'not_found'; error?: string } + +const notFound: ArkResolution = { type: 'not_found' } + +/** + * Resolve `ark:NAAN/name` (anything before "ark:" is ignored) for a caller. + * Returns null when the string isn't an ARK at all. + */ +export async function resolveArk( + ports: Ports, + principal: Principal | null, + path: string, +): Promise { + const { db } = ports + const arkLabelIdx = path.indexOf('ark:') + if (arkLabelIdx === -1) return null + + let afterLabel = path.slice(arkLabelIdx + 4) + // "ark:/NAAN/…" is the older spelling of "ark:NAAN/…"; the ARK spec treats them as one. + if (afterLabel.startsWith('/')) afterLabel = afterLabel.slice(1) + const slashIdx = afterLabel.indexOf('/') + if (slashIdx === -1) return notFound + const naan = afterLabel.slice(0, slashIdx) + const pathAfterNaan = afterLabel.slice(slashIdx + 1) + if (!pathAfterNaan) return notFound + + let components + try { + components = parseArkPath(pathAfterNaan) + } catch { + // decodeURIComponent throws on malformed escapes + return notFound + } + if (!components) return notFound + const { shoulder, collectionArkId, version, recordType, recordId } = components + + const [shoulderRow] = await db + .select({ organizationId: schema.arkShoulders.organizationId }) + .from(schema.arkShoulders) + .where(eq(schema.arkShoulders.shoulder, shoulder)) + .limit(1) + if (!shoulderRow) return notFound + + const [row] = await db + .select({ + ark: schema.arkCollections, + collection: schema.collections, + owner: schema.organization, + }) + .from(schema.arkCollections) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) + .innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id)) + .where(eq(schema.arkCollections.arkId, collectionArkId)) + .limit(1) + if (!row || !row.ark.enabled) return notFound + // The shoulder must be the collection owner's: another org's shoulder in + // front of this collection's id is not its ARK. + if (shoulderRow.organizationId !== row.collection.organizationId) return notFound + + // Same visibility as the collection page: a private collection doesn't + // resolve (or reveal its name, owner and versions) to non-members. + const access = await collectionAccess(db, principal, row.owner.slug, row.collection.slug) + if (!access?.canRead) return notFound + const member = access.isMember + + const { collection, owner } = row + const resolvedNaan = owner.arkNaan ?? naan + + let v: VersionRow | null = null + if (version !== undefined) { + v = await findVersion(db, collection.id, version, collection.headVersionId) + if (!v) return notFound + } else if (collection.headVersionId) { + v = await findVersion(db, collection.id, 'latest', collection.headVersionId) + } + + const arkUrl = buildArkUrl( + resolvedNaan, + shoulder, + collectionArkId, + version !== undefined ? v!.semver : undefined, + recordType, + recordId, + ) + const base = { + who: owner.name, + where: arkUrl, + naan: resolvedNaan, + collectionName: collection.name, + ownerName: owner.name, + } + // Who pushed is for members only, as on the versions routes (v1 leaked it here). + const versionFields = (x: VersionRow) => ({ + semver: x.semver, + message: x.message, + ...(member ? { pushedBy: x.pushedBy, actorId: x.actorId } : {}), + appId: x.appId, + createdAt: x.createdAt, + }) + + if (recordType && recordId) { + const [rt] = await db + .select({ redirectUrlField: schema.arkRecordTypes.redirectUrlField }) + .from(schema.arkRecordTypes) + .where( + and( + eq(schema.arkRecordTypes.collectionId, collection.id), + eq(schema.arkRecordTypes.recordType, recordType), + ), + ) + .limit(1) + if (!rt || !v) return notFound + + // Non-members get the public set only: a private-set record is not found. + const repo = await ports.stores.forCollection(collection.id) + const view = await loadView(repo, v, member, await deniedHashes(db)) + const type = view.types.find((t) => t.slug === recordType) + const rec = type ? await getRecord(view, type, recordId) : null + if (!type || !rec) return notFound + const { data } = JSON.parse(rec.body!) as { data: Record } + const typeSchema = await repo.schema(type.schemaHash) + + // Only http(s) targets: anything else (javascript:, data:) is an open redirect. + const redirectUrl = data?.[rt.redirectUrlField] + if (!isHttpUrl(redirectUrl)) return { type: 'not_found', error: 'No URL found for this record' } + + return { + type: 'redirect', + url: redirectUrl, + metadata: { + type: 'record', + ...base, + what: `${recordType} ${recordId} in ${collection.name}`, + when: formatErcDate(v.createdAt), + semver: v.semver, + recordType, + recordId, + schema: typeSchema, + data, + createdAt: v.createdAt, + arkUrl, + }, + } + } + + const kind = version !== undefined ? 'version' : 'collection' + if (row.ark.customUrl) { + return { + type: 'redirect', + url: row.ark.customUrl, + metadata: { + type: kind, + ...base, + what: v ? `${collection.name} ${v.semver}` : collection.name, + when: v ? formatErcDate(v.createdAt) : '(:unkn)', + ...(v ? versionFields(v) : {}), + arkUrl, + }, + } + } + + if (version !== undefined && v) { + // Page URLs use the bare semver (/v/1.0.0); stored semver is "v1.0.0". + return { + type: 'redirect', + url: `/${owner.slug}/${collection.slug}/v/${v.semver.replace(/^v/, '')}`, + metadata: { + type: 'version', + ...base, + what: `${collection.name} ${v.semver}`, + when: formatErcDate(v.createdAt), + ...versionFields(v), + arkUrl, + }, + } + } + + return { + type: 'redirect', + url: `/${owner.slug}/${collection.slug}`, + metadata: { + type: 'collection', + ...base, + what: collection.name, + when: v ? formatErcDate(v.createdAt) : '(:unkn)', + ...(v ? { semver: v.semver, createdAt: v.createdAt } : {}), + arkUrl, + }, + } +} + +// --- /ark: URLs --- + +const policy = (naan: string) => + [ + `The Underlay assigns identifiers within the ARK domain ${naan} with the following principles:`, + '', + '1. Persistence: ARKs are never reassigned. Once minted, an ARK will always resolve to the same collection or record, or return a tombstone response if the object has been deleted.', + '', + '2. Transparency: Appending ?info or ?? to any ARK returns an Electronic Resource Citation (ERC) describing the identified object.', + '', + '3. Openness: ARKs are free, open identifiers requiring no licensing fees. The Underlay uses the ARK scheme as specified by the ARK Alliance.', + '', + '4. Scope: Underlay ARKs primarily identify versioned data collections and the records within them. Collection ARKs redirect to the collection overview; version-qualified ARKs redirect to specific version pages; record ARKs redirect to the canonical URL of the identified record.', + '', + `For more information, see: https://underlay.org/ark:${naan}/`, + ].join('\n') + +const text = (body: string, status = 200) => + new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } }) + +/** GET /ark:NAAN/…: the NAAN's policy, an ERC (?info, ??), the metadata (?json), or a 302. */ +async function arkPage(c: Context): Promise { + const url = new URL(c.req.url) + const fullPath = url.pathname.slice(1) + let afterLabel = fullPath.slice(4) + if (afterLabel.startsWith('/')) afterLabel = afterLabel.slice(1) + const slashIdx = afterLabel.indexOf('/') + const naan = slashIdx === -1 ? afterLabel : afterLabel.slice(0, slashIdx) + const afterNaan = slashIdx === -1 ? '' : afterLabel.slice(slashIdx + 1) + if (!afterNaan.trim()) return text(policy(naan)) + + // In process, as the caller: a Worker can't fetch its own zone, and members + // should resolve what they can read. + const res = await resolveArk(c.var.ports, c.var.principal, fullPath) + if (!res || res.type === 'not_found') return text('ARK not found', 404) + + const { metadata } = res + const search = url.search + if (search === '?info' || search === '??' || search === '%3F%3F') { + const m = metadata as Record + return text( + buildErc({ + type: metadata.type as 'collection' | 'version' | 'record', + who: m.who ?? m.ownerName ?? '(:unkn)', + what: m.what ?? m.collectionName ?? '(:unkn)', + when: m.when ?? '(:unkn)', + where: m.where ?? m.arkUrl ?? '(:unkn)', + naan: m.naan ?? DEFAULT_NAAN, + }), + ) + } + if (search === '?json') { + return new Response(JSON.stringify(metadata, null, 2), { + headers: { 'content-type': 'application/json' }, + }) + } + // The deployment's public origin, not the request's: behind a proxy the + // request URL can be the internal one. + const target = res.url.startsWith('/') ? `${c.var.config.appUrl}${res.url}` : res.url + return c.redirect(target, 302) +} + +// --- Settings access --- + +/** + * ARK settings are for members of the owning org (v1: any role). A private + * collection stays a 404 to everyone else; a public one is 401/403. + */ +async function requireArkMember( + c: Context, + write: boolean, +): Promise { + const access = await collectionAccess( + c.var.ports.db, + c.var.principal, + c.req.param('owner') ?? '', + c.req.param('slug') ?? '', + ) + if (!access?.canRead) return jsonError(c, 404, 'Collection not found') + if (!access.isMember || (write && !access.canWrite)) { + return c.var.principal + ? jsonError(c, 403, 'Forbidden') + : jsonError(c, 401, 'Authentication required') + } + return access +} + +async function setRecordType( + c: Context, + collectionId: string, + recordType: string, + redirectUrlField: string | null, +) { + const { db } = c.var.ports + if (redirectUrlField === null) { + await db + .delete(schema.arkRecordTypes) + .where( + and( + eq(schema.arkRecordTypes.collectionId, collectionId), + eq(schema.arkRecordTypes.recordType, recordType), + ), + ) + } else { + await db + .insert(schema.arkRecordTypes) + .values({ collectionId, recordType, redirectUrlField }) + .onConflictDoUpdate({ + target: [schema.arkRecordTypes.collectionId, schema.arkRecordTypes.recordType], + set: { redirectUrlField }, + }) + } + return c.json({ ok: true }) +} + +export function arkRoutes() { + const app = new Hono() + + app.get('/api/ark/resolve', async (c) => { + const path = c.req.query('path') + if (!path) return jsonError(c, 400, 'Missing path') + const res = await resolveArk(c.var.ports, c.var.principal, path) + if (!res) return jsonError(c, 400, 'Invalid ARK path') + return res.type === 'not_found' ? c.json(res, 404) : c.json(res) + }) + + app.get('/api/collections/:owner/:slug/ark', async (c) => { + const access = await requireArkMember(c, false) + if (access instanceof Response) return access + const { db } = c.var.ports + const [row] = await db + .select() + .from(schema.arkCollections) + .where(eq(schema.arkCollections.collectionId, access.collection.id)) + .limit(1) + const shoulder = row ? await orgShoulder(db, access.owner.id) : null + if (!row || !shoulder) + return c.json({ enabled: false, customUrl: null, arkUrl: null, shoulder: null, arkId: null }) + return c.json({ + enabled: row.enabled, + customUrl: row.customUrl, + arkUrl: buildArkUrl(access.owner.arkNaan ?? DEFAULT_NAAN, shoulder, row.arkId), + shoulder, + arkId: row.arkId, + }) + }) + + app.patch('/api/collections/:owner/:slug/ark', async (c) => { + const access = await requireArkMember(c, true) + if (access instanceof Response) return access + const body = (await c.req.json().catch(() => null)) as { + enabled?: unknown + customUrl?: unknown + } | null + if (!body) return jsonError(c, 400, 'Invalid JSON') + const { enabled, customUrl } = body + if (enabled !== undefined && typeof enabled !== 'boolean') + return jsonError(c, 400, 'enabled must be a boolean') + // The resolver redirects to customUrl, so only http(s) targets are allowed — + // anything else (javascript:, data:, protocol-relative) is an open redirect. + if (customUrl != null && customUrl !== '' && !isHttpUrl(customUrl)) + return jsonError(c, 422, 'customUrl must be an http(s) URL') + const url = customUrl === undefined ? undefined : (customUrl as string | null) || null + + const { db } = c.var.ports + const coll = access.collection + const [existing] = await db + .select({ collectionId: schema.arkCollections.collectionId }) + .from(schema.arkCollections) + .where(eq(schema.arkCollections.collectionId, coll.id)) + .limit(1) + if (!existing) { + // v2 collections aren't minted an ARK at creation (yet): mint on first enable. + await getOrMintShoulder(db, coll.organizationId) + await db.insert(schema.arkCollections).values({ + collectionId: coll.id, + arkId: collectionToArkId(coll.id), + enabled: enabled ?? true, + customUrl: url ?? null, + }) + } else { + const updates: { enabled?: boolean; customUrl?: string | null } = {} + if (enabled !== undefined) updates.enabled = enabled + if (url !== undefined) updates.customUrl = url + if (Object.keys(updates).length > 0) + await db + .update(schema.arkCollections) + .set(updates) + .where(eq(schema.arkCollections.collectionId, coll.id)) + } + // collection.json carries the collection's ARK (spec 11.1). + await c.var.ports.jobs.enqueue({ type: 'collection.info', collectionId: coll.id }) + return c.json({ ok: true }) + }) + + app.get('/api/collections/:owner/:slug/ark/record-types', async (c) => { + const access = await requireArkMember(c, false) + if (access instanceof Response) return access + const rows = await c.var.ports.db + .select({ + recordType: schema.arkRecordTypes.recordType, + redirectUrlField: schema.arkRecordTypes.redirectUrlField, + }) + .from(schema.arkRecordTypes) + .where(eq(schema.arkRecordTypes.collectionId, access.collection.id)) + .orderBy(asc(schema.arkRecordTypes.recordType)) + return c.json(rows) + }) + + // PATCH is v1's (null removes); PUT sets and DELETE removes, for clients that prefer them. + for (const method of ['patch', 'put'] as const) { + app[method]('/api/collections/:owner/:slug/ark/record-types', async (c) => { + const access = await requireArkMember(c, true) + if (access instanceof Response) return access + const body = (await c.req.json().catch(() => null)) as { + recordType?: unknown + redirectUrlField?: unknown + } | null + const { recordType, redirectUrlField } = body ?? {} + if (typeof recordType !== 'string' || !recordType) + return jsonError(c, 400, 'recordType required') + const removes = method === 'patch' && redirectUrlField === null + if (!removes && (typeof redirectUrlField !== 'string' || !redirectUrlField)) + return jsonError(c, 400, 'redirectUrlField required') + return setRecordType( + c, + access.collection.id, + recordType, + removes ? null : (redirectUrlField as string), + ) + }) + } + + app.delete('/api/collections/:owner/:slug/ark/record-types/:recordType', async (c) => { + const access = await requireArkMember(c, true) + if (access instanceof Response) return access + return setRecordType(c, access.collection.id, c.req.param('recordType'), null) + }) + + app.patch('/api/accounts/:slug/ark', async (c) => { + const { db } = c.var.ports + const body = (await c.req.json().catch(() => null)) as { naan?: unknown } | null + const naan = body?.naan + if (naan !== null && (typeof naan !== 'string' || !/^\d{1,16}$/.test(naan))) + return jsonError(c, 400, 'NAAN must be numeric (up to 16 digits)') + + const [org] = await db + .select() + .from(schema.organization) + .where(eq(schema.organization.slug, c.req.param('slug'))) + .limit(1) + if (!org) return jsonError(c, 404, 'Org not found') + + // Owner or admin of the org, as in v1, acting with a session or an admin key + // (capRole). Scoped, read-only and write keys can't. + const p = c.var.principal + if (!p) return jsonError(c, 401, 'Authentication required') + let role: string | null = null + if (!p.collectionIds && p.scope !== 'read') { + if (p.orgId) role = p.orgId === org.id ? 'owner' : null + else { + const [m] = await db + .select({ role: schema.member.role }) + .from(schema.member) + .where(and(eq(schema.member.organizationId, org.id), eq(schema.member.userId, p.userId))) + .limit(1) + role = m?.role ?? null + } + role = capRole(p, role) + } + if (role !== 'owner' && role !== 'admin') return jsonError(c, 403, 'Forbidden') + + // Whether a NAAN is registered to the org can't be checked here, but it must + // not collide with the instance's own NAAN or another org's claim — ARK + // resolution keys on it. + if (naan !== null) { + const [taken] = await db + .select({ id: schema.organization.id }) + .from(schema.organization) + .where(and(eq(schema.organization.arkNaan, naan), ne(schema.organization.id, org.id))) + .limit(1) + if (naan === DEFAULT_NAAN || taken) return jsonError(c, 409, 'That NAAN is already in use') + } + await db + .update(schema.organization) + .set({ arkNaan: naan }) + .where(eq(schema.organization.id, org.id)) + await c.var.ports.jobs.enqueue({ type: 'collection.info.org', organizationId: org.id }) + return c.json({ ok: true }) + }) + + // `{ark:.+}` matches across slashes: /ark:NAAN/name/Type/id. + app.get('/:ark{ark:.+}', arkPage) + + return app +} + +function isHttpUrl(value: unknown): value is string { + if (typeof value !== 'string') return false + try { + const { protocol } = new URL(value) + return protocol === 'https:' || protocol === 'http:' + } catch { + return false + } +} diff --git a/packages/server/src/api/avatars.ts b/packages/server/src/api/avatars.ts new file mode 100644 index 0000000..86e2864 --- /dev/null +++ b/packages/server/src/api/avatars.ts @@ -0,0 +1,114 @@ +/** + * Org logos, stored in the deployment's public assets bucket and linked from + * `organization.avatar_url` as an absolute URL (as v1 did). What's stored and + * what may be deleted is in lib/avatars.ts. + * + * POST /api/accounts/:owner/avatar multipart/form-data, one image file + * DELETE /api/accounts/:owner/avatar + * + * Owners of the org only, as for its other account routes: a session or an + * unscoped personal key. Read-only, collection-scoped and org-owned keys can't. + */ +import { eq } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import * as schema from '../db/schema.js' +import { deleteOwnedAvatar, sniffRaster } from '../lib/avatars.js' +import type { PublicAssets } from '../ports.js' +import { jsonError } from './access.js' +import { orgBySlug, requireUser, roleIn } from './accounts.js' +import { BodyTooLarge, readBytes } from './body.js' + +/** Logos show at a couple of hundred pixels; GitHub caps profile pictures at 1 MB too. */ +export const AVATAR_MAX_BYTES = 1024 * 1024 +/** Multipart framing around the file. */ +const MULTIPART_OVERHEAD = 64 * 1024 +const CACHE_CONTROL = 'public, max-age=31536000, immutable' + +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] + +type Org = typeof schema.organization.$inferSelect + +/** The org, when the caller owns it; otherwise the error response. */ +async function ownedOrg(c: Context): Promise { + const userId = requireUser(c, true) + if (userId instanceof Response) return userId + const org = await orgBySlug(c, c.req.param('owner') ?? '') + if (!org) return jsonError(c, 404, 'Organization not found') + if ((await roleIn(c, org.id, userId)) !== 'owner') + return jsonError(c, 403, 'Must be an owner to update the organization avatar') + return org +} + +function assetsOr503(c: Context): PublicAssets | Response { + return ( + c.var.ports.publicAssets ?? + jsonError(c, 503, 'Avatar uploads are not configured on this deployment') + ) +} + +export function avatarRoutes() { + const app = new Hono() + + app.post('/api/accounts/:owner/avatar', async (c) => { + const org = await ownedOrg(c) + if (org instanceof Response) return org + const assets = assetsOr503(c) + if (assets instanceof Response) return assets + + const contentType = c.req.header('content-type') ?? '' + if (!contentType.startsWith('multipart/form-data')) + return jsonError(c, 400, 'Send the image as multipart/form-data') + let form: FormData + try { + const body = await readBytes(c, AVATAR_MAX_BYTES + MULTIPART_OVERHEAD) + form = await new Response(body, { headers: { 'content-type': contentType } }).formData() + } catch (err) { + if (err instanceof BodyTooLarge) + return jsonError(c, 413, `Image must be ${AVATAR_MAX_BYTES / 1024 / 1024} MiB or smaller`) + return jsonError(c, 400, 'Malformed multipart body') + } + const file = [...form.values()].find((v): v is File => typeof v !== 'string') + if (!file) return jsonError(c, 400, 'No file uploaded') + if (file.size > AVATAR_MAX_BYTES) + return jsonError(c, 413, `Image must be ${AVATAR_MAX_BYTES / 1024 / 1024} MiB or smaller`) + + // The declared type is the browser's guess from the file name; the bytes decide. + const bytes = new Uint8Array(await file.arrayBuffer()) + const raster = sniffRaster(bytes) + if (!ALLOWED_TYPES.includes(file.type) || !raster) + return jsonError(c, 422, 'Only JPEG, PNG, GIF, and WebP images are allowed') + + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)) + const hash = [...digest].map((v) => v.toString(16).padStart(2, '0')).join('') + const key = `avatars/${org.id}/${hash}.${raster.ext}` + await assets.store.put(key, bytes, { contentType: raster.type, cacheControl: CACHE_CONTROL }) + + const avatarUrl = `${assets.baseUrl}/${key}` + await c.var.ports.db + .update(schema.organization) + .set({ avatarUrl }) + .where(eq(schema.organization.id, org.id)) + if (org.avatarUrl !== avatarUrl) await deleteOwnedAvatar(assets, org.id, org.avatarUrl) + + return c.json({ ok: true, avatarUrl }) + }) + + app.delete('/api/accounts/:owner/avatar', async (c) => { + const org = await ownedOrg(c) + if (org instanceof Response) return org + const assets = assetsOr503(c) + if (assets instanceof Response) return assets + + await c.var.ports.db + .update(schema.organization) + .set({ avatarUrl: null }) + .where(eq(schema.organization.id, org.id)) + await deleteOwnedAvatar(assets, org.id, org.avatarUrl) + + return c.json({ ok: true }) + }) + + return app +} diff --git a/packages/server/src/api/body.ts b/packages/server/src/api/body.ts new file mode 100644 index 0000000..bf2dfa7 --- /dev/null +++ b/packages/server/src/api/body.ts @@ -0,0 +1,97 @@ +/** + * Request bodies read under limits: a size cap enforced while streaming, and for + * JSON the protocol's input rules (duplicate keys, unsafe integers, lone + * surrogates, depth), which a parsed value can no longer show. + */ +import { InputRuleError, parseStrict } from '@underlay/protocol' +import type { Context } from 'hono' + +import type { AppEnv } from '../app.js' +import { jsonError } from './access.js' + +export class BodyTooLarge extends Error {} + +/** Read a request body, refusing more than `max` bytes without buffering them. */ +export async function readBytes(c: Context, max: number): Promise> { + const declared = Number(c.req.header('content-length') ?? NaN) + if (declared > max) throw new BodyTooLarge() + const body = c.req.raw.body + if (!body) return new Uint8Array() + const reader = body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > max) { + await reader.cancel() + throw new BodyTooLarge() + } + chunks.push(value) + } + return Buffer.concat(chunks) +} + +/** Read a request body as text, refusing more than `max` bytes without buffering them. */ +export async function readText(c: Context, max: number): Promise { + return new TextDecoder().decode(await readBytes(c, max)) +} + +/** + * A request body's non-blank lines, decoded as they arrive, under the same cap + * as readText: no copy of the whole body is ever held (v2-scale-review.md S6). + * Throws BodyTooLarge from the iteration once more than `max` bytes arrive. + */ +export async function* readLines(c: Context, max: number): AsyncGenerator { + const declared = Number(c.req.header('content-length') ?? NaN) + if (declared > max) throw new BodyTooLarge() + const body = c.req.raw.body + if (!body) return + const reader = body.getReader() + const decoder = new TextDecoder() + let rest = '' + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > max) { + await reader.cancel() + throw new BodyTooLarge() + } + const text = rest + decoder.decode(value, { stream: true }) + let start = 0 + for (let nl = text.indexOf('\n'); nl >= 0; nl = text.indexOf('\n', start)) { + const line = text.slice(start, nl) + start = nl + 1 + if (line.trim()) yield line + } + rest = text.slice(start) + } + rest += decoder.decode() + if (rest.trim()) yield rest +} + +export type JsonBody = Record + +/** Parse a JSON object body under the input rules; an empty body is `{}`. */ +export async function readJson(c: Context, max: number): Promise { + let text: string + try { + text = await readText(c, max) + } catch (err) { + if (err instanceof BodyTooLarge) return jsonError(c, 413, `Body exceeds ${max} bytes`) + throw err + } + if (text.trim() === '') return {} + try { + const v = parseStrict(text, 128) + if (v === null || typeof v !== 'object' || Array.isArray(v)) + return jsonError(c, 400, 'Body must be a JSON object') + return v as JsonBody + } catch (err) { + if (err instanceof InputRuleError) return jsonError(c, 400, `${err.code}: ${err.message}`) + throw err + } +} diff --git a/packages/server/src/api/cleanup.ts b/packages/server/src/api/cleanup.ts new file mode 100644 index 0000000..b039172 --- /dev/null +++ b/packages/server/src/api/cleanup.ts @@ -0,0 +1,187 @@ +/** + * Storage cleanup for stewards (planning: v2-storage-cleanup.md): + * + * GET /api/admin/cleanup runs, what's waiting, the fence, the automatic switch + * POST /api/admin/cleanup/runs {step: internal|mark|sweep, dryRun?, thenSweep?} starts a run + * PUT /api/admin/cleanup/auto {enabled} the weekly automatic mark and sweep + * PUT /api/admin/cleanup/pause {paused} no marks or sweeps start, and sweeps delete nothing + */ +import { and, desc, eq, gt, inArray, isNull, lt, sql } from 'drizzle-orm' +import { Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import { AUTO_SETTING, cleanupConfig, cleanupPaused, PAUSE_SETTING } from '../cleanup/config.js' +import { fenceConfig, fenceState } from '../cleanup/fence.js' +import { CleanupRefused, startRun } from '../cleanup/runs.js' +import * as schema from '../db/schema.js' +import { jsonError } from './access.js' +import { stewardOnly } from './admin.js' + +const STEPS: schema.CleanupStep[] = ['internal', 'mark', 'sweep'] +const TERMINAL: schema.SessionStatus[] = ['committed', 'failed', 'expired'] + +export function cleanupRoutes() { + const app = new Hono() + + app.use('/api/admin/cleanup', async (c, next) => (await stewardOnly(c)) ?? next()) + app.use('/api/admin/cleanup/*', async (c, next) => (await stewardOnly(c)) ?? next()) + + app.get('/api/admin/cleanup', async (c) => { + const { db } = c.var.ports + const now = Date.now() + const graceStart = new Date(now - cleanupConfig.tombstoneGraceMs) + const [runs, fence, auto, sessions, uploads, lastSweep, lastMark] = await Promise.all([ + db.select().from(schema.cleanupRuns).orderBy(desc(schema.cleanupRuns.createdAt)).limit(30), + fenceState(db), + db + .select({ value: schema.instanceSettings.value }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.key, AUTO_SETTING)), + db + .select({ + ready: sql`sum(CASE WHEN ${schema.pushSessions.expiresAt} < ${now - cleanupConfig.sessionGraceMs} THEN 1 ELSE 0 END)`, + later: sql`count(*)`, + }) + .from(schema.pushSessions) + .where( + and(isNull(schema.pushSessions.cleanedAt), inArray(schema.pushSessions.status, TERMINAL)), + ), + db + .select({ n: sql`count(*)` }) + .from(schema.fileUploads) + .where( + and( + eq(schema.fileUploads.status, 'pending'), + lt(schema.fileUploads.createdAt, new Date(now - cleanupConfig.uploadGraceMs)), + ), + ), + db + .select() + .from(schema.cleanupRuns) + .where( + and( + eq(schema.cleanupRuns.step, 'sweep'), + eq(schema.cleanupRuns.status, 'done'), + eq(schema.cleanupRuns.dryRun, false), + ), + ) + .orderBy(desc(schema.cleanupRuns.createdAt)) + .limit(1), + db + .select() + .from(schema.cleanupRuns) + .where(and(eq(schema.cleanupRuns.step, 'mark'), eq(schema.cleanupRuns.status, 'done'))) + .orderBy(desc(schema.cleanupRuns.createdAt)) + .limit(1), + ]) + + // Deleted collections a finished sweep hasn't reached: those still in their + // grace period when its mark began, or deleted since. + const markedAt = Number(lastSweep[0]?.state?.markStartedAt ?? 0) + const since = new Date(markedAt ? markedAt - cleanupConfig.tombstoneGraceMs : 0) + const [deleted] = await db + .select({ + inGrace: sql`sum(CASE WHEN ${schema.collectionTombstones.deletedAt} > ${graceStart.getTime()} THEN 1 ELSE 0 END)`, + inGraceBytes: sql`sum(CASE WHEN ${schema.collectionTombstones.deletedAt} > ${graceStart.getTime()} THEN ${schema.collectionTombstones.totalBytes} ELSE 0 END)`, + ready: sql`sum(CASE WHEN ${schema.collectionTombstones.deletedAt} <= ${graceStart.getTime()} THEN 1 ELSE 0 END)`, + readyBytes: sql`sum(CASE WHEN ${schema.collectionTombstones.deletedAt} <= ${graceStart.getTime()} THEN ${schema.collectionTombstones.totalBytes} ELSE 0 END)`, + }) + .from(schema.collectionTombstones) + .where(gt(schema.collectionTombstones.deletedAt, since)) + + const until = fence?.windowUntil?.getTime() + return c.json({ + runs: runs.map((r) => ({ + id: r.id, + step: r.step, + status: r.status, + trigger: r.trigger, + dryRun: r.dryRun, + requestedBy: r.requestedBy, + markRunId: r.markRunId, + error: r.error, + stats: r.stats, + createdAt: r.createdAt.getTime(), + startedAt: r.startedAt?.getTime() ?? null, + finishedAt: r.finishedAt?.getTime() ?? null, + })), + auto: auto[0]?.value === true, + paused: await cleanupPaused(db), + fence: { + epoch: fence?.epoch ?? 0, + windowOpen: until !== undefined && until > now - fenceConfig.slackMs, + }, + waiting: { + sessions: Number(sessions[0]?.ready ?? 0), + sessionsInGrace: Number(sessions[0]?.later ?? 0) - Number(sessions[0]?.ready ?? 0), + uploads: Number(uploads[0]?.n ?? 0), + }, + deletedCollections: { + inGrace: { + collections: Number(deleted?.inGrace ?? 0), + bytes: Number(deleted?.inGraceBytes ?? 0), + }, + ready: { + collections: Number(deleted?.ready ?? 0), + bytes: Number(deleted?.readyBytes ?? 0), + }, + }, + lastMark: lastMark[0] + ? { + id: lastMark[0].id, + startedAt: lastMark[0].startedAt?.getTime() ?? null, + finishedAt: lastMark[0].finishedAt?.getTime() ?? null, + marked: lastMark[0].stats?.marked ?? 0, + } + : null, + config: { + sessionGraceHours: cleanupConfig.sessionGraceMs / 3_600_000, + uploadGraceHours: cleanupConfig.uploadGraceMs / 3_600_000, + tombstoneGraceDays: cleanupConfig.tombstoneGraceMs / 86_400_000, + }, + }) + }) + + app.post('/api/admin/cleanup/runs', async (c) => { + const b = (await c.req.json().catch(() => null)) as { + step?: unknown + dryRun?: unknown + thenSweep?: unknown + } | null + const step = b?.step as schema.CleanupStep + if (!STEPS.includes(step)) return jsonError(c, 400, 'step is internal, mark or sweep') + try { + const run = await startRun(c.var.ports, step, { + trigger: 'manual', + dryRun: b?.dryRun === true, + thenSweep: b?.thenSweep === true, + requestedBy: c.var.principal?.userId ?? null, + }) + return c.json({ ok: true, id: run.id }, 202) + } catch (err) { + if (err instanceof CleanupRefused) return jsonError(c, 409, err.message) + throw err + } + }) + + for (const [path, key, field] of [ + ['/api/admin/cleanup/auto', AUTO_SETTING, 'enabled'], + ['/api/admin/cleanup/pause', PAUSE_SETTING, 'paused'], + ] as const) { + app.put(path, async (c) => { + const b = (await c.req.json().catch(() => null)) as Record | null + const value = b?.[field] + if (typeof value !== 'boolean') return jsonError(c, 400, `${field} must be true or false`) + await c.var.ports.db + .insert(schema.instanceSettings) + .values({ key, value, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: schema.instanceSettings.key, + set: { value, updatedAt: new Date() }, + }) + return c.json({ ok: true, [field]: value }) + }) + } + + return app +} diff --git a/packages/server/src/api/collections.ts b/packages/server/src/api/collections.ts new file mode 100644 index 0000000..93c3904 --- /dev/null +++ b/packages/server/src/api/collections.ts @@ -0,0 +1,436 @@ +/** + * Collection, account and app-context reads (v1 shapes: docs/v1-read-api.md). + * + * GET /api/context + * GET /api/collections list and explore + * GET /api/collections/:owner/:slug detail (spec 11.3.3, plus v1's members) + * GET /api/accounts/:owner/collections + * GET /api/accounts/:slug, /api/accounts/:slug/members + * + * Everything here is SQLite rows: no repository reads. Lists show what a + * caller may see: public counts to non-members, full counts to members. + */ +import { and, asc, count, desc, eq, inArray, like, or, sql } from 'drizzle-orm' +import { Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import { chunks } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import type { Db } from '../ports.js' +import { jsonError, requireCollection } from './access.js' +import { collectionArk } from './ark.js' + +type VersionRow = typeof schema.versions.$inferSelect + +/** Org ids the principal belongs to (empty for anonymous or collection-scoped keys). */ +async function memberOrgIds( + db: Db, + userId: string | undefined, + scoped: boolean, +): Promise { + if (!userId || scoped) return [] + const rows = await db + .select({ id: schema.member.organizationId }) + .from(schema.member) + .where(eq(schema.member.userId, userId)) + return rows.map((r) => r.id) +} + +/** A version as a caller sees it in lists and the collection detail. */ +export function versionSummary( + v: VersionRow, + owner: boolean, + ark: ((semver: string) => string) | null = null, +) { + return { + semver: v.semver, + major: v.major, + minor: v.minor, + patch: v.patch, + hash: v.hash, + baseSemver: v.baseSemver, + message: v.message, + appId: v.appId, + ...(owner ? { pushedBy: v.pushedBy, actorId: v.actorId } : {}), + recordCount: owner ? v.recordCount : v.publicRecordCount, + fileCount: owner ? v.fileCount : v.publicFileCount, + totalBytes: owner ? v.totalBytes : v.publicTotalBytes, + typeCounts: owner ? v.typeCounts : v.publicTypeCounts, + createdAt: v.createdAt, + ark: ark ? ark(v.semver) : null, + } +} + +/** + * Versions with their pusher's name and personal account slug (the account + * page at /) added, where the caller sees `pushedBy`. + */ +export async function withPusherNames( + db: Db, + versions: T[], +): Promise<(T & { pushedByName?: string | null; pushedBySlug?: string | null })[]> { + const ids = [...new Set(versions.map((v) => v.pushedBy).filter((id): id is string => !!id))] + if (ids.length === 0) return versions + const names = new Map() + const slugs = new Map() + for (const part of chunks(ids)) { + const rows = await db + .select({ id: schema.user.id, name: schema.user.name }) + .from(schema.user) + .where(inArray(schema.user.id, part)) + for (const r of rows) names.set(r.id, r.name) + const own = await db + .select({ userId: schema.member.userId, slug: schema.organization.slug }) + .from(schema.member) + .innerJoin(schema.organization, eq(schema.organization.id, schema.member.organizationId)) + .where(and(inArray(schema.member.userId, part), eq(schema.organization.isDefault, true))) + for (const r of own) slugs.set(r.userId, r.slug) + } + return versions.map((v) => + v.pushedBy === undefined + ? v + : { + ...v, + pushedByName: names.get(v.pushedBy ?? '') ?? null, + pushedBySlug: slugs.get(v.pushedBy ?? '') ?? null, + }, + ) +} + +export function collectionRoutes() { + const app = new Hono() + + app.get('/api/context', async (c) => { + const { db } = c.var.ports + const p = c.var.principal + let currentUser = null + if (p && p.scope === 'session') { + const [u] = await db.select().from(schema.user).where(eq(schema.user.id, p.userId)).limit(1) + if (u) { + const orgs = await db + .select({ org: schema.organization, role: schema.member.role }) + .from(schema.member) + .innerJoin(schema.organization, eq(schema.organization.id, schema.member.organizationId)) + .where(eq(schema.member.userId, u.id)) + const def = orgs.find((o) => o.org.isDefault) ?? null + // The role decides steward pages; name and picture follow KF Auth (v1). + const profile = (await c.var.kf?.profile(u.id)) ?? null + currentUser = { + id: u.id, + slug: def?.org.slug ?? null, + displayName: profile?.name ?? u.name ?? def?.org.name ?? null, + avatarUrl: profile?.image ?? u.image ?? null, + kfRole: profile?.role ?? null, + defaultOrg: def ? { slug: def.org.slug, displayName: def.org.name } : null, + orgs: orgs.map((o) => ({ + organizationId: o.org.id, + slug: o.org.slug, + displayName: o.org.name, + role: o.role, + isDefault: o.org.isDefault, + })), + } + } + } + return c.json({ + currentUser, + kfAccountUrl: c.var.config.kfAccountUrl ?? '', + kfAuthUrl: c.var.config.kfAuthUrl ?? '', + // The deployment's host, for "this becomes the URL" hints. + siteHost: new URL(c.var.config.appUrl).host, + }) + }) + + app.get('/api/collections', async (c) => { + const { db } = c.var.ports + const p = c.var.principal + const q = c.req.query('q') + const owner = c.req.query('owner') + const tag = c.req.query('tag') + const sort = c.req.query('sort') + const mine = c.req.query('mine') === 'true' + const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 50) || 50)) + const offset = Math.max(0, Number(c.req.query('offset') ?? 0) || 0) + const scoped = !!p?.collectionIds + if (mine && (!p || scoped)) + return jsonError(c, 401, 'Unauthorized — mine=true requires a session') + const orgIds = await memberOrgIds(db, p?.userId, scoped) + + // A subquery, not the list: D1 binds at most 100 parameters per statement. + const myOrgs = db + .select({ id: schema.member.organizationId }) + .from(schema.member) + .where(eq(schema.member.userId, p?.userId ?? '')) + const visible = mine + ? inArray(schema.collections.organizationId, myOrgs) + : eq(schema.collections.public, true) + const conds = [visible, sql`${schema.collections.deletedAt} IS NULL`] + if (q) conds.push(like(schema.collections.name, `%${q}%`)) + if (owner) conds.push(eq(schema.organization.slug, owner)) + if (tag) { + conds.push( + sql`EXISTS (SELECT 1 FROM json_each(${schema.collections.summary}, '$.tags') WHERE value = ${tag})`, + ) + } + const where = and(...conds) + const order = + sort === 'name' + ? [asc(schema.collections.name)] + : sort === 'records' + ? [desc(schema.versions.publicRecordCount)] + : [desc(schema.collections.updatedAt)] + + const rows = await db + .select({ c: schema.collections, owner: schema.organization, v: schema.versions }) + .from(schema.collections) + .innerJoin(schema.organization, eq(schema.organization.id, schema.collections.organizationId)) + .leftJoin(schema.versions, eq(schema.versions.id, schema.collections.headVersionId)) + .where(where) + .orderBy(...order) + .limit(limit) + .offset(offset) + + const item = (r: (typeof rows)[number]) => { + const isOwner = orgIds.includes(r.c.organizationId) + const v = r.v ? versionSummary(r.v, isOwner) : null + return { + id: r.c.id, + slug: r.c.slug, + name: r.c.name, + public: r.c.public, + ownerSlug: r.owner.slug, + ownerName: r.owner.name, + createdAt: r.c.createdAt, + updatedAt: r.c.updatedAt, + description: r.c.summary?.description ?? null, + tags: r.c.summary?.tags ?? [], + latestVersion: v?.semver ?? null, + recordCount: v?.recordCount ?? null, + fileCount: v?.fileCount ?? null, + totalBytes: v?.totalBytes ?? null, + lastPushAt: v?.createdAt ?? null, + } + } + + // Facets over everything visible (not just this page). + const ownerFacets = await db + .select({ slug: schema.organization.slug, name: schema.organization.name, count: count() }) + .from(schema.collections) + .innerJoin(schema.organization, eq(schema.organization.id, schema.collections.organizationId)) + .where(and(visible, sql`${schema.collections.deletedAt} IS NULL`)) + .groupBy(schema.organization.id) + .orderBy(desc(count())) + .limit(50) + const tagFacets = (await db.all(sql` + SELECT t.value AS name, count(*) AS count + FROM ${schema.collections} c, json_each(c.summary, '$.tags') t + WHERE ${mine ? inArray(sql`c.organization_id`, myOrgs) : sql`c.public = 1`} + AND c.deleted_at IS NULL + GROUP BY t.value ORDER BY count DESC LIMIT 50 + `)) as { name: string; count: number }[] + + const settings = await db + .select() + .from(schema.instanceSettings) + .where( + inArray(schema.instanceSettings.key, [ + 'explore_featured_tags', + 'explore_featured_collections', + ]), + ) + const setting = (k: string) => settings.find((s) => s.key === k)?.value + const featuredTags = Array.isArray(setting('explore_featured_tags')) + ? (setting('explore_featured_tags') as string[]) + : [] + const featuredRefs = Array.isArray(setting('explore_featured_collections')) + ? (setting('explore_featured_collections') as string[]) + : [] + let featuredCollections: ReturnType[] = [] + if (featuredRefs.length > 0) { + const pairs = featuredRefs.map((r) => r.split('/')).filter((x) => x.length === 2) + const featured = await db + .select({ c: schema.collections, owner: schema.organization, v: schema.versions }) + .from(schema.collections) + .innerJoin( + schema.organization, + eq(schema.organization.id, schema.collections.organizationId), + ) + .leftJoin(schema.versions, eq(schema.versions.id, schema.collections.headVersionId)) + .where( + and( + eq(schema.collections.public, true), + or( + ...pairs.map(([o, s]) => + and(eq(schema.organization.slug, o!), eq(schema.collections.slug, s!)), + ), + ), + ), + ) + featuredCollections = featuredRefs + .map((ref) => featured.find((f) => `${f.owner.slug}/${f.c.slug}` === ref)) + .filter((f): f is (typeof featured)[number] => !!f) + .map(item) + } + + return c.json({ + collections: rows.map(item), + facets: { owners: ownerFacets, tags: tagFacets }, + featuredTags, + featuredCollections, + }) + }) + + app.get('/api/collections/:owner/:slug', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const { db } = c.var.ports + const col = access.collection + const ark = await collectionArk(db, col.id, access.owner) + const [versionCount] = await db + .select({ n: count() }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, col.id)) + let latestVersion = null + let head: { semver: string; hash: string } | null = null + if (col.headVersionId) { + const [v] = await db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, col.headVersionId)) + .limit(1) + if (v) { + head = { semver: v.semver, hash: v.hash } + const repo = await c.var.ports.stores.forCollection(col.id) + const root = await repo.root(v.hash) + const s = (await withPusherNames(db, [versionSummary(v, access.isMember, ark)]))[0]! + latestVersion = { + ...s, + metadata: root.metadata, + typeCounts: Object.entries(s.typeCounts).map(([type, n]) => ({ type, count: n })), + } + } + } + // The standard members (spec 11.3.3) mirror collection.json; `public`, + // `ownerSlug`, `ownerName` and `latestVersion` are v1's, which the UI reads. + return c.json({ + id: col.id, + owner: { + id: access.owner.id, + did: null, + handle: access.owner.slug, + name: access.owner.name, + }, + slug: col.slug, + name: col.name, + description: col.summary?.description ?? null, + visibility: col.public ? 'public' : 'private', + ark: ark ? ark() : null, + createdAt: col.createdAt, + updatedAt: col.updatedAt, + versionCount: versionCount?.n ?? 0, + head, + public: col.public, + ownerSlug: access.owner.slug, + ownerName: access.owner.name, + latestVersion, + }) + }) + + app.get('/api/accounts/:owner/collections', async (c) => { + const { db } = c.var.ports + const p = c.var.principal + const [org] = await db + .select() + .from(schema.organization) + .where(eq(schema.organization.slug, c.req.param('owner'))) + .limit(1) + if (!org) return c.json([]) + const orgIds = await memberOrgIds(db, p?.userId, !!p?.collectionIds) + const member = orgIds.includes(org.id) + const rows = await db + .select({ + id: schema.collections.id, + slug: schema.collections.slug, + name: schema.collections.name, + public: schema.collections.public, + createdAt: schema.collections.createdAt, + updatedAt: schema.collections.updatedAt, + }) + .from(schema.collections) + .where( + and( + eq(schema.collections.organizationId, org.id), + sql`${schema.collections.deletedAt} IS NULL`, + member ? undefined : eq(schema.collections.public, true), + ), + ) + .orderBy(desc(schema.collections.updatedAt)) + return c.json(rows) + }) + + app.get('/api/accounts/:slug', async (c) => { + const { db } = c.var.ports + const [org] = await db + .select() + .from(schema.organization) + .where(eq(schema.organization.slug, c.req.param('slug'))) + .limit(1) + if (!org) return jsonError(c, 404, 'Not found') + const [shoulder] = await db + .select({ shoulder: schema.arkShoulders.shoulder }) + .from(schema.arkShoulders) + .where(eq(schema.arkShoulders.organizationId, org.id)) + .limit(1) + // Public profile only (v1 returned the whole row, including kfOrgId). + return c.json({ + id: org.id, + name: org.name, + slug: org.slug, + displayName: org.name, + avatarUrl: org.avatarUrl, + logo: org.logo, + bio: org.bio, + website: org.website, + createdAt: org.createdAt, + isDefault: org.isDefault, + arkNaan: org.arkNaan, + arkShoulder: shoulder?.shoulder ?? null, + }) + }) + + app.get('/api/accounts/:slug/members', async (c) => { + const { db } = c.var.ports + const [org] = await db + .select() + .from(schema.organization) + .where(eq(schema.organization.slug, c.req.param('slug'))) + .limit(1) + if (!org) return jsonError(c, 404, 'Not found') + const members = await db + .select({ role: schema.member.role, userId: schema.member.userId, name: schema.user.name }) + .from(schema.member) + .innerJoin(schema.user, eq(schema.user.id, schema.member.userId)) + .where(eq(schema.member.organizationId, org.id)) + const defaults: { userId: string; slug: string }[] = [] + for (const part of chunks(members.map((m) => m.userId))) { + defaults.push( + ...(await db + .select({ userId: schema.member.userId, slug: schema.organization.slug }) + .from(schema.member) + .innerJoin(schema.organization, eq(schema.organization.id, schema.member.organizationId)) + .where( + and(inArray(schema.member.userId, part), eq(schema.organization.isDefault, true)), + )), + ) + } + return c.json( + members.map((m) => ({ + role: m.role, + slug: defaults.find((d) => d.userId === m.userId)?.slug ?? null, + displayName: m.name, + })), + ) + }) + + return app +} diff --git a/packages/server/src/api/export.ts b/packages/server/src/api/export.ts new file mode 100644 index 0000000..f8ede34 --- /dev/null +++ b/packages/server/src/api/export.ts @@ -0,0 +1,193 @@ +/** + * GET /api/collections/:owner/:slug/export?version=&format=tar|tar.gz + * + * A tar archive of what the caller may read (v1 layout, plus README.md): + * manifest.json collection, version, schemas, missing and withheld files + * README.md the version's metadata.readme, when there is one + * records/.ndjson one {id,type,data,hash} per line + * files/ file bytes + * + * Streams with no record count limit: tar needs sizes up front, and the trees + * give them without reading records (a type's NDJSON is its canonical bytes plus + * a fixed `,"hash":"…"` and newline per record). gzip costs Worker CPU per byte, + * so very large exports should ask for format=tar. + * + * Denylisted hashes are left out: records are withheld (when any are blocked, a + * pass over each type's tree nodes, not its bodies, gives the exact sizes), and + * files are listed as withheld instead of sent. + */ +import { fileTree, iterate, RepoSource, type TarEntry, tarStream } from '@underlay/protocol' +import { Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import { chunks, inJson, JSON_CHUNK } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import { deniedHashes } from '../lib/limits.js' +import { findVersion, loadView, typeRecords } from '../versions/view.js' +import { jsonError, requireCollection } from './access.js' + +const enc = new TextEncoder() +// `{"id":…}` → `{"id":…,"hash":"<64 hex>"}` adds 74 bytes; plus the newline. +const PER_RECORD_EXTRA = 75 + +export function exportRoutes() { + const app = new Hono() + + app.get('/:owner/:slug/export', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const format = c.req.query('format') ?? 'tar.gz' + if (format !== 'tar' && format !== 'tar.gz') + return jsonError(c, 400, `Unknown format "${format.slice(0, 40)}": use tar or tar.gz`) + const ports = c.var.ports + const v = await findVersion( + ports.db, + access.collection.id, + c.req.query('version') ?? 'latest', + access.collection.headVersionId, + ) + if (!v) return jsonError(c, 404, 'No versions found') + const repo = await ports.stores.forCollection(access.collection.id) + const denied = await deniedHashes(c.var.ports.db) + const view = await loadView(repo, v, access.isMember, denied) + const gzip = format === 'tar.gz' + + // Each type's NDJSON size and count, from its tree totals unless records are withheld. + const sizes = new Map() + for (const t of view.types) { + if (denied.size === 0) { + sizes.set(t.slug, { count: t.count, bytes: t.bytes + t.count * PER_RECORD_EXTRA }) + continue + } + const s = { count: 0, bytes: 0 } + for await (const e of typeRecords(view, t)) { + s.count++ + s.bytes += e.size + PER_RECORD_EXTRA + } + sizes.set(t.slug, s) + } + + // Files the caller may read, and which of them the platform has bytes for. + const fileSource = new RepoSource(fileTree, repo) + const fileHashes = new Map() + for (const r of [view.public.files.root, view.private?.files.root ?? null]) { + for await (const f of iterate(fileSource, r)) fileHashes.set(f.key, f.size) + } + const withheldFiles = [...fileHashes.keys()].filter((h) => denied.has(h)) + for (const h of withheldFiles) fileHashes.delete(h) + const rows: (typeof schema.files.$inferSelect)[] = [] + const list = [...fileHashes.keys()] + for (const part of chunks(list, JSON_CHUNK)) { + rows.push( + ...(await ports.db.select().from(schema.files).where(inJson(schema.files.hash, part))), + ) + } + const stored = new Map(rows.map((r) => [r.hash, r])) + + const schemas: Record = {} + for (const t of view.types) schemas[t.slug] = await repo.schema(t.schemaHash) + const recordCount = [...sizes.values()].reduce((n, s) => n + s.count, 0) + const manifest = enc.encode( + JSON.stringify( + { + collection: { + id: access.collection.id, + owner: access.owner.slug, + slug: access.collection.slug, + name: access.collection.name, + description: access.collection.summary?.description ?? null, + }, + version: { + semver: v.semver, + hash: v.hash, + message: v.message, + recordCount, + fileCount: fileHashes.size, + totalBytes: + [...sizes.values()].reduce((n, s) => n + s.bytes - s.count * PER_RECORD_EXTRA, 0) + + [...fileHashes.values()].reduce((a, b) => a + b, 0), + createdAt: v.createdAt, + }, + schemas, + files_missing: list.filter((h) => !stored.has(h)), + files_withheld: withheldFiles, + }, + null, + 2, + ), + ) + const readme = + typeof view.root.metadata?.readme === 'string' ? enc.encode(view.root.metadata.readme) : null + + const entries = async function* (): AsyncGenerator { + yield { + name: 'manifest.json', + size: manifest.byteLength, + body: async function* () { + yield manifest + }, + } + if (readme) + yield { + name: 'README.md', + size: readme.byteLength, + body: async function* () { + yield readme + }, + } + for (const t of view.types) { + yield { + name: `records/${t.slug}.ndjson`, + size: sizes.get(t.slug)!.bytes, + body: async function* () { + let batch: string[] = [] + for await (const e of typeRecords(view, t, { bodies: true })) { + batch.push(`${e.body!.slice(0, -1)},"hash":"${e.hash}"}\n`) + if (batch.length === 512) { + yield enc.encode(batch.join('')) + batch = [] + } + } + if (batch.length) yield enc.encode(batch.join('')) + }, + } + } + for (const [hash, size] of fileHashes) { + const f = stored.get(hash) + if (!f) continue + yield { + name: `files/${hash}`, + size, + body: async function* () { + const obj = await ports.stores.fileBytes.get(f.storageKey) + if (!obj) throw new Error(`File ${hash} is missing from storage`) + const reader = obj.body.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) return + yield value + } + }, + } + } + } + + // Entries are dated when the version was made, so a version exports to the + // same bytes every time (gzip's header carries no time: CompressionStream + // writes 0). + let body = tarStream(entries(), v.createdAt.getTime()) + if (gzip) + body = body.pipeThrough( + new CompressionStream('gzip') as unknown as TransformStream, + ) + const name = `${access.owner.slug}-${access.collection.slug}-${v.semver}.tar${gzip ? '.gz' : ''}` + return new Response(body, { + headers: { + 'content-type': gzip ? 'application/gzip' : 'application/x-tar', + 'content-disposition': `attachment; filename="${name}"`, + }, + }) + }) + + return app +} diff --git a/packages/server/src/api/files.ts b/packages/server/src/api/files.ts new file mode 100644 index 0000000..6e653b7 --- /dev/null +++ b/packages/server/src/api/files.ts @@ -0,0 +1,222 @@ +/** + * File routes, mounted at /api/collections. + * + * HEAD|GET /:owner/:slug/files/:hash 302 to a presigned URL (v1 contract) + * POST /:owner/:slug/files/presign {hashes} → {hash: url|null} (read-only) + * PUT /:owner/:slug/files/:hash small upload through the API + * POST /:owner/:slug/files/uploads start a direct upload → presigned PUT or parts + * POST /:owner/:slug/files/uploads/:id/complete + * GET /:owner/:slug/files/uploads/:id pending | verifying | verified | failed + */ +import { and, eq } from 'drizzle-orm' +import { Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import { meter } from '../billing/usage.js' +import * as schema from '../db/schema.js' +import { + canReadFile, + cleanHash, + completeUpload, + isHash, + MAX_FILE_BYTES, + presignParts, + presignDownload, + presignDownloads, + PRESIGN_SECONDS, + readableFiles, + safeMimeType, + SMALL_UPLOAD_BYTES, + startUpload, + storeSmallFile, +} from '../files/files.js' +import { isDenied } from '../lib/limits.js' +import { fileSizes } from '../versions/file-refs.js' +import { jsonError, requireCollection } from './access.js' + +export function fileRoutes() { + const app = new Hono() + + // Hono routes HEAD to GET handlers, so one handler serves both. + app.get('/:owner/:slug/files/:hash', async (c) => { + const head = c.req.method === 'HEAD' + const access = await requireCollection(c, 'read') + if (access instanceof Response) return head ? c.body(null, 404) : access + const hash = cleanHash(c.req.param('hash')) + // Access before the denylist: a 451 would confirm the file exists to anyone. + if ( + !isHash(hash) || + !(await canReadFile(c.var.ports, access.collection, access.isMember, hash)) + ) { + return head ? c.body(null, 404) : jsonError(c, 404, 'File not found') + } + if (await isDenied(c.var.ports.db, hash)) { + return head ? c.body(null, 451) : jsonError(c, 451, 'This file is unavailable') + } + const [f] = await c.var.ports.db + .select() + .from(schema.files) + .where(eq(schema.files.hash, hash)) + .limit(1) + if (!f) return head ? c.body(null, 404) : jsonError(c, 404, 'File not found') + if (head) + return c.body(null, 200, { 'content-length': String(f.size), 'content-type': f.mimeType }) + meter(c.var.meter, 'file_downloads', 1) + meter(c.var.meter, 'file_bytes', f.size) + // The presigned URL lasts PRESIGN_SECONDS: a browser may reuse the redirect until shortly before. + c.header('cache-control', `private, max-age=${PRESIGN_SECONDS - 60}`) + return c.redirect((await presignDownload(c.var.ports, hash))!, 302) + }) + + app.post('/:owner/:slug/files/presign', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const body = (await c.req.json().catch(() => null)) as { hashes?: unknown } | null + if (!Array.isArray(body?.hashes) || body.hashes.length > 500) { + return jsonError(c, 400, '"hashes" must be an array of at most 500 file hashes') + } + // The head's trees and the file rows are read once, not per hash (D1: 1,000 queries). + const requested = body.hashes.filter((h): h is string => typeof h === 'string') + const valid = requested.map(cleanHash).filter(isHash) + const readable = await readableFiles(c.var.ports, access.collection, access.isMember, valid) + const urls = await presignDownloads(c.var.ports, [...readable]) + const out: Record = {} + for (const r of requested) out[r] = urls.get(cleanHash(r)) ?? null + // A presigned URL is a download handed out: metered as one, at the file's size. + for (const s of await fileSizes(c.var.ports.db, [...urls.keys()])) { + meter(c.var.meter, 'file_downloads', 1) + meter(c.var.meter, 'file_bytes', s[1]) + } + return c.json(out) + }) + + app.put('/:owner/:slug/files/:hash', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const hash = cleanHash(c.req.param('hash')) + if (!isHash(hash)) return jsonError(c, 400, 'Not a sha256 file hash') + const declared = Number(c.req.header('content-length') ?? NaN) + if (declared > SMALL_UPLOAD_BYTES) { + return jsonError( + c, + 413, + `Uploads through the API are limited to ${SMALL_UPLOAD_BYTES} bytes; use POST .../files/uploads for larger files`, + ) + } + const type = c.req.header('content-type') ?? 'application/octet-stream' + let bytes: Uint8Array + let mime: string + if (type.startsWith('multipart/')) { + const form = await c.req.parseBody() + const file = form.file + if (!(file instanceof File)) return jsonError(c, 400, 'No file in multipart body') + bytes = new Uint8Array(await file.arrayBuffer()) + mime = file.type + } else { + bytes = new Uint8Array(await c.req.arrayBuffer()) + mime = type + } + if (bytes.byteLength > SMALL_UPLOAD_BYTES) + return jsonError(c, 413, `File exceeds ${SMALL_UPLOAD_BYTES} bytes`) + const result = await storeSmallFile( + c.var.ports, + access.collection.id, + hash, + bytes, + safeMimeType(mime), + ) + if (result === 'mismatch') return jsonError(c, 400, 'Hash mismatch', { expected: hash }) + return c.json({ hash, status: 'stored', size: bytes.byteLength }, 201) + }) + + app.post('/:owner/:slug/files/uploads', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const body = (await c.req.json().catch(() => null)) as { + hash?: unknown + size?: unknown + mimeType?: unknown + } | null + const hash = typeof body?.hash === 'string' ? cleanHash(body.hash) : '' + const size = Number(body?.size) + if (!isHash(hash)) return jsonError(c, 400, '"hash" must be a sha256 file hash') + if (!Number.isSafeInteger(size) || size < 0) + return jsonError(c, 400, '"size" must be a byte count') + if (size > MAX_FILE_BYTES) return jsonError(c, 413, 'A file is at most 5 TiB') + const ticket = await startUpload(c.var.ports, access.collection.id, { + hash, + size, + mimeType: safeMimeType(typeof body?.mimeType === 'string' ? body.mimeType : undefined), + }) + return c.json(ticket, 201) + }) + + app.post('/:owner/:slug/files/uploads/:id/complete', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const [u] = await c.var.ports.db + .select() + .from(schema.fileUploads) + .where( + and( + eq(schema.fileUploads.id, c.req.param('id')), + eq(schema.fileUploads.collectionId, access.collection.id), + ), + ) + .limit(1) + if (!u) return jsonError(c, 404, 'Upload not found') + const body = (await c.req.json().catch(() => ({}))) as { parts?: unknown } | null + const parts = body?.parts + if (u.multipartUploadId) { + const valid = + Array.isArray(parts) && + parts.length > 0 && + parts.every( + (p) => + Number.isSafeInteger(p?.partNumber) && p.partNumber > 0 && typeof p.etag === 'string', + ) + if (!valid) return jsonError(c, 400, 'parts is required for a multipart upload') + } + await completeUpload(c.var.ports, u, parts as { partNumber: number; etag: string }[]) + return c.json({ id: u.id, status: 'verifying' }, 202) + }) + + app.get('/:owner/:slug/files/uploads/:id/parts', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const from = Number(c.req.query('from') ?? 1) + if (!Number.isSafeInteger(from) || from < 1) return jsonError(c, 400, '"from" is a part number') + const [u] = await c.var.ports.db + .select() + .from(schema.fileUploads) + .where( + and( + eq(schema.fileUploads.id, c.req.param('id')), + eq(schema.fileUploads.collectionId, access.collection.id), + eq(schema.fileUploads.status, 'pending'), + ), + ) + .limit(1) + if (!u || !u.multipartUploadId) return jsonError(c, 404, 'Upload not found') + return c.json({ parts: await presignParts(c.var.ports, u, from) }) + }) + + app.get('/:owner/:slug/files/uploads/:id', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const [u] = await c.var.ports.db + .select() + .from(schema.fileUploads) + .where( + and( + eq(schema.fileUploads.id, c.req.param('id')), + eq(schema.fileUploads.collectionId, access.collection.id), + ), + ) + .limit(1) + if (!u) return jsonError(c, 404, 'Upload not found') + return c.json({ id: u.id, hash: u.hash, size: u.size, status: u.status, error: u.error }) + }) + + return app +} diff --git a/packages/server/src/api/locations.ts b/packages/server/src/api/locations.ts new file mode 100644 index 0000000..83699fd --- /dev/null +++ b/packages/server/src/api/locations.ts @@ -0,0 +1,395 @@ +/** + * Storage locations and mirror placements (edge-redesign.md, "Placements"). + * Org owners and admins manage them; members can read mirror status. + * + * GET /api/orgs/:org/locations + * POST /api/orgs/:org/locations {name, endpoint, bucket, prefix?, + * accessKeyId, secretAccessKey} + * (422 and not kept when its first check fails) + * POST /api/orgs/:org/locations/:id/check + * DELETE /api/orgs/:org/locations/:id (its placements go too; bucket contents stay) + * GET /api/orgs/:org/placements org defaults, inherited by every collection + * POST /api/orgs/:org/placements {locationId, sets} + * DELETE /api/orgs/:org/placements/:id and the org's mirrors to that location + * GET /api/collections/:owner/:slug/placements primary and mirrors, with progress, lag and + * whether each mirror comes from an org default + * POST /api/collections/:owner/:slug/placements {locationId, sets} + * POST /api/collections/:owner/:slug/placements/:id/sync + * DELETE /api/collections/:owner/:slug/placements/:id (409 for one from an org default) + * + * A location that serves objects without credentials may only hold public sets. + * Deleting a mirror never deletes what it copied: the bucket is the customer's. + */ +import { and, eq, inArray, isNull } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import * as schema from '../db/schema.js' +import { + checkEndpoint, + namedRegion, + checkLocation, + encryptCredentials, + type LocationRow, +} from '../locations/locations.js' +import { collectionMirrors, queueMirrors } from '../locations/mirror.js' +import { jsonError, requireCollection } from './access.js' +import { isAdmin, membership } from './manage.js' + +const str = (v: unknown, max = 512) => + typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null + +function locationView(l: LocationRow) { + return { + id: l.id, + name: l.name, + kind: l.kind, + endpoint: l.endpoint, + region: l.region, + bucket: l.bucket, + prefix: l.prefix, + status: l.status, + lastError: l.lastError, + verifiedAt: l.verifiedAt, + } +} + +/** Local endpoints over http (MinIO, a test fake) only on dev and test deployments. */ +const allowInsecure = (c: Context) => + c.var.config.deployment === 'dev' || c.var.config.deployment === 'test' + +async function orgAdmin(c: Context) { + if (!c.var.principal) return jsonError(c, 401, 'Authentication required') + const { org, role } = await membership(c, c.req.param('org') ?? '') + if (!org) return jsonError(c, 404, 'Org not found') + if (!isAdmin(role)) return jsonError(c, 403, 'Only org owners and admins manage storage') + return org +} + +/** An org's location, by id. */ +async function orgLocation(c: Context, orgId: string, id: string) { + const [loc] = await c.var.ports.db + .select() + .from(schema.storageLocations) + .where( + and(eq(schema.storageLocations.id, id), eq(schema.storageLocations.organizationId, orgId)), + ) + return loc ?? null +} + +/** An org's default placements, which its collections inherit. */ +function orgDefaults(c: Context, orgId: string) { + return c.var.ports.db + .select() + .from(schema.placements) + .where(and(eq(schema.placements.organizationId, orgId), isNull(schema.placements.collectionId))) +} + +/** Whether a location may hold private sets: checked now, refused if public. */ +async function allowSets(c: Context, loc: LocationRow, sets: string) { + if (sets !== 'public' && sets !== 'public+private') { + return jsonError(c, 400, '"sets" is "public" or "public+private"') + } + if (sets === 'public+private' && !loc.endpoint?.startsWith('https:') && !allowInsecure(c)) { + return jsonError(c, 422, 'Private sets need an https endpoint') + } + const check = await checkLocation(c.var.ports, loc) + if (!check.ok) return jsonError(c, 422, `The location failed its check: ${check.error}`) + if (sets === 'public+private' && check.publicRead) { + return jsonError( + c, + 422, + 'The location serves objects without credentials; it can hold public sets only', + ) + } + return null +} + +export function locationRoutes() { + const app = new Hono() + + app.get('/api/orgs/:org/locations', async (c) => { + const org = await orgAdmin(c) + if (org instanceof Response) return org + const rows = await c.var.ports.db + .select() + .from(schema.storageLocations) + .where(eq(schema.storageLocations.organizationId, org.id)) + return c.json({ locations: rows.map(locationView) }) + }) + + app.post('/api/orgs/:org/locations', async (c) => { + const org = await orgAdmin(c) + if (org instanceof Response) return org + const ports = c.var.ports + if (!ports.locationKey) + return jsonError(c, 409, 'This deployment has no LOCATION_KEY; locations are unavailable') + const body = (await c.req.json().catch(() => ({}))) as Record + const endpointError = checkEndpoint(body.endpoint, allowInsecure(c)) + if (endpointError) return jsonError(c, 400, endpointError, { field: 'endpoint' }) + const name = str(body.name, 200) + const bucket = str(body.bucket, 255) + const accessKeyId = str(body.accessKeyId) + const secretAccessKey = str(body.secretAccessKey) + if (!name || !bucket || !accessKeyId || !secretAccessKey) { + return jsonError(c, 400, 'Enter a name, the bucket, the access key and the secret.') + } + const prefix = (str(body.prefix, 512) ?? '').replace(/^\/+|\/+$/g, '') + const id = crypto.randomUUID() + const endpoint = (body.endpoint as string).replace(/\/+$/, '') + await ports.db.insert(schema.storageLocations).values({ + id, + organizationId: org.id, + kind: 's3', + name, + endpoint, + // Null signs as us-east-1; the check fills it in when the bucket names its region. + region: namedRegion(endpoint), + bucket, + prefix, + credentials: await encryptCredentials(ports, id, { accessKeyId, secretAccessKey }), + }) + const loc = (await orgLocation(c, org.id, id))! + const check = await checkLocation(ports, loc) + // A location exists only once it has worked; Re-check is for ones that break later. + if (!check.ok) { + await ports.db.delete(schema.storageLocations).where(eq(schema.storageLocations.id, id)) + return jsonError(c, 422, check.error ?? 'The location check failed.', { check }) + } + return c.json({ location: locationView((await orgLocation(c, org.id, id))!), check }, 201) + }) + + app.post('/api/orgs/:org/locations/:id/check', async (c) => { + const org = await orgAdmin(c) + if (org instanceof Response) return org + const loc = await orgLocation(c, org.id, c.req.param('id')) + if (!loc) return jsonError(c, 404, 'Location not found') + const check = await checkLocation(c.var.ports, loc) + return c.json({ location: locationView((await orgLocation(c, org.id, loc.id))!), check }) + }) + + app.delete('/api/orgs/:org/locations/:id', async (c) => { + const org = await orgAdmin(c) + if (org instanceof Response) return org + const loc = await orgLocation(c, org.id, c.req.param('id')) + if (!loc) return jsonError(c, 404, 'Location not found') + await c.var.ports.db + .delete(schema.storageLocations) + .where(eq(schema.storageLocations.id, loc.id)) + return c.body(null, 204) + }) + + app.get('/api/orgs/:org/placements', async (c) => { + const org = await orgAdmin(c) + if (org instanceof Response) return org + const rows = await orgDefaults(c, org.id) + return c.json({ + placements: rows.map((p) => ({ id: p.id, locationId: p.locationId, sets: p.sets })), + }) + }) + + app.post('/api/orgs/:org/placements', async (c) => { + const org = await orgAdmin(c) + if (org instanceof Response) return org + const body = (await c.req.json().catch(() => ({}))) as { locationId?: unknown; sets?: unknown } + const loc = await orgLocation(c, org.id, String(body.locationId ?? '')) + if (!loc) return jsonError(c, 404, 'Location not found') + const sets = String(body.sets ?? 'public') + const refused = await allowSets(c, loc, sets) + if (refused) return refused + const { db } = c.var.ports + const [row] = await db + .insert(schema.placements) + .values({ + organizationId: org.id, + locationId: loc.id, + role: 'mirror', + sets: sets as 'public' | 'public+private', + }) + .onConflictDoNothing() + .returning() + if (!row) return jsonError(c, 409, 'The org already mirrors to this location') + // Every existing collection inherits it now; new ones on their first publish. + const cols = await db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where(eq(schema.collections.organizationId, org.id)) + for (const col of cols) await queueMirrors(c.var.ports, col.id) + return c.json({ placement: { id: row.id, locationId: row.locationId, sets: row.sets } }, 201) + }) + + app.delete('/api/orgs/:org/placements/:id', async (c) => { + const org = await orgAdmin(c) + if (org instanceof Response) return org + const { db } = c.var.ports + const [row] = await db + .select() + .from(schema.placements) + .where( + and( + eq(schema.placements.id, c.req.param('id')), + eq(schema.placements.organizationId, org.id), + ), + ) + if (!row) return jsonError(c, 404, 'Placement not found') + await db.batch([ + db.delete(schema.placements).where(eq(schema.placements.id, row.id)), + db + .delete(schema.placements) + .where( + and( + eq(schema.placements.locationId, row.locationId), + eq(schema.placements.role, 'mirror'), + inArray( + schema.placements.collectionId, + db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where(eq(schema.collections.organizationId, org.id)), + ), + ), + ), + ]) + return c.body(null, 204) + }) + + app.get('/api/collections/:owner/:slug/placements', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + if (!access.isMember) return jsonError(c, 404, 'Collection not found') + const { db } = c.var.ports + await collectionMirrors(c.var.ports, access.collection.id) + const rows = await db + .select({ p: schema.placements, l: schema.storageLocations }) + .from(schema.placements) + .innerJoin( + schema.storageLocations, + eq(schema.storageLocations.id, schema.placements.locationId), + ) + .where(eq(schema.placements.collectionId, access.collection.id)) + const [head] = access.collection.headVersionId + ? await db + .select({ seq: schema.versions.seq }) + .from(schema.versions) + .where(eq(schema.versions.id, access.collection.headVersionId)) + : [] + const headSeq = head?.seq ?? 0 + const inherited = new Set( + (await orgDefaults(c, access.collection.organizationId)).map((d) => d.locationId), + ) + return c.json({ + headSeq, + placements: rows.map(({ p, l }) => ({ + id: p.id, + role: p.role, + sets: p.sets, + // From an org default: removed with the default, not per collection. + inherited: p.role === 'mirror' && inherited.has(p.locationId), + state: p.state, + syncedSeq: p.role === 'primary' ? headSeq : p.syncedSeq, + lag: p.role === 'primary' ? 0 : Math.max(0, headSeq - p.syncedSeq), + lastError: p.lastError, + updatedAt: p.updatedAt, + location: { + id: l.id, + name: l.name, + kind: l.kind, + bucket: l.bucket, + prefix: l.prefix, + status: l.status, + }, + })), + }) + }) + + app.post('/api/collections/:owner/:slug/placements', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + if (!isAdmin(access.role)) return jsonError(c, 403, 'Only org owners and admins manage mirrors') + const body = (await c.req.json().catch(() => ({}))) as { locationId?: unknown; sets?: unknown } + const loc = await orgLocation(c, access.owner.id, String(body.locationId ?? '')) + if (!loc) return jsonError(c, 404, 'Location not found') + const sets = String(body.sets ?? 'public') + const refused = await allowSets(c, loc, sets) + if (refused) return refused + const [row] = await c.var.ports.db + .insert(schema.placements) + .values({ + collectionId: access.collection.id, + locationId: loc.id, + role: 'mirror', + sets: sets as 'public' | 'public+private', + state: 'backfilling', + }) + .onConflictDoNothing() + .returning() + if (!row) return jsonError(c, 409, 'The collection already mirrors to this location') + await c.var.ports.jobs.enqueue({ type: 'mirror.version', placementId: row.id }) + return c.json( + { placement: { id: row.id, locationId: loc.id, sets: row.sets, state: row.state } }, + 201, + ) + }) + + app.post('/api/collections/:owner/:slug/placements/:id/sync', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + if (!isAdmin(access.role)) return jsonError(c, 403, 'Only org owners and admins manage mirrors') + const { db } = c.var.ports + const [p] = await db + .update(schema.placements) + .set({ state: 'backfilling', lastError: null, updatedAt: new Date() }) + .where( + and( + eq(schema.placements.id, c.req.param('id')), + eq(schema.placements.collectionId, access.collection.id), + eq(schema.placements.role, 'mirror'), + ), + ) + .returning() + if (!p) return jsonError(c, 404, 'Placement not found') + await c.var.ports.jobs.enqueue({ type: 'mirror.version', placementId: p.id }) + return c.json({ placement: { id: p.id, state: p.state } }, 202) + }) + + app.delete('/api/collections/:owner/:slug/placements/:id', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + if (!isAdmin(access.role)) return jsonError(c, 403, 'Only org owners and admins manage mirrors') + const { db } = c.var.ports + const [p] = await db + .select({ locationId: schema.placements.locationId }) + .from(schema.placements) + .where( + and( + eq(schema.placements.id, c.req.param('id')), + eq(schema.placements.collectionId, access.collection.id), + eq(schema.placements.role, 'mirror'), + ), + ) + if (!p) return jsonError(c, 404, 'Placement not found') + // The next publish would make it again from the default. + const defaults = await orgDefaults(c, access.collection.organizationId) + if (defaults.some((d) => d.locationId === p.locationId)) { + return jsonError( + c, + 409, + "This mirror comes from the org's default placements; remove it in the org's storage settings", + ) + } + const rows = await db + .delete(schema.placements) + .where( + and( + eq(schema.placements.id, c.req.param('id')), + eq(schema.placements.collectionId, access.collection.id), + eq(schema.placements.role, 'mirror'), + ), + ) + .returning({ id: schema.placements.id }) + if (rows.length === 0) return jsonError(c, 404, 'Placement not found') + return c.body(null, 204) + }) + + return app +} diff --git a/packages/server/src/api/manage.ts b/packages/server/src/api/manage.ts new file mode 100644 index 0000000..80cf0a0 --- /dev/null +++ b/packages/server/src/api/manage.ts @@ -0,0 +1,317 @@ +/** + * Creating and changing collections (v1 shapes). + * + * POST /api/accounts/:owner/collections {slug, name?, description?, public?} + * PATCH /api/collections/:owner/:slug {name?, slug?, public?} + * DELETE /api/collections/:owner/:slug + * POST /api/collections/:owner/:slug/transfer {targetOrgSlug} + * POST /api/collections/:owner/:slug/fork {targetOrg, slug?} + * POST /api/collections/:owner/:slug/metadata {...metadata patch} + * + * Collection settings are mutable rows; nothing here rewrites version data. + * A metadata edit is a new version that reuses every set: one root object. + */ +import { and, count, eq, sql } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import { fenced } from '../cleanup/fence.js' +import * as schema from '../db/schema.js' +import { validateCollectionSlug } from '../lib/slug.js' +import { headBase } from '../push/delta.js' +import { commitVersion } from '../versions/commit.js' +import { createCollectionRows, forkCollection } from '../versions/fork.js' +import { capRole, jsonError, keyCannotManage, requireCollection } from './access.js' +import { ensureCollectionArk } from './ark.js' +import { readJson } from './body.js' + +/** A metadata patch, like a push's opening body, is at most 8 MB. */ +const MAX_METADATA_BODY = 8 * 1024 * 1024 + +/** The org named `orgSlug` and the caller's role in it (null when not a member). */ +export async function membership(c: Context, orgSlug: string) { + const p = c.var.principal + const [org] = await c.var.ports.db + .select() + .from(schema.organization) + .where(eq(schema.organization.slug, orgSlug)) + .limit(1) + if (!org) return { org: null, role: null } + if (!p || p.collectionIds || p.scope === 'read') return { org, role: null } + if (p.orgId) return { org, role: p.orgId === org.id ? capRole(p, 'owner') : null } + const [m] = await c.var.ports.db + .select({ role: schema.member.role }) + .from(schema.member) + .where(and(eq(schema.member.organizationId, org.id), eq(schema.member.userId, p.userId))) + .limit(1) + return { org, role: capRole(p, m?.role ?? null) } +} + +export const isAdmin = (role: string | null) => role === 'owner' || role === 'admin' + +export function manageRoutes() { + const app = new Hono() + + app.post('/api/accounts/:owner/collections', async (c) => { + if (!c.var.principal) return jsonError(c, 401, 'Authentication required') + const { org, role } = await membership(c, c.req.param('owner')) + if (!org) return jsonError(c, 404, 'Org not found') + if (!role) return jsonError(c, 403, 'Forbidden') + const body = (await c.req.json().catch(() => null)) as { + slug?: unknown + name?: unknown + description?: unknown + public?: unknown + } | null + const slugError = validateCollectionSlug(body?.slug) + if (slugError) return jsonError(c, 422, slugError) + const slug = body!.slug as string + const [taken] = await c.var.ports.db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where(and(eq(schema.collections.organizationId, org.id), eq(schema.collections.slug, slug))) + .limit(1) + if (taken) return jsonError(c, 409, 'Collection already exists') + const name = + typeof body?.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 200) : slug + const description = + typeof body?.description === 'string' && body.description.trim() + ? body.description.trim().slice(0, 1000) + : null + const col = await createCollectionRows(c.var.ports, { + organizationId: org.id, + slug, + name, + public: body?.public === true, + ...(description ? { summary: { description } } : {}), + }) + // Every new collection gets an ARK, as in v1. + await ensureCollectionArk(c.var.ports.db, { id: col.id, organizationId: org.id }) + return c.json({ id: col.id, owner: org.slug, slug, name }, 201) + }) + + app.patch('/api/collections/:owner/:slug', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const body = (await c.req.json().catch(() => ({}))) as { + name?: unknown + slug?: unknown + public?: unknown + } + const set: Partial = {} + if (typeof body.name === 'string' && body.name.trim()) set.name = body.name.trim().slice(0, 200) + if (body.public !== undefined) { + // Visibility is an admin decision (v1 rule): it changes who can read every version. + const refused = keyCannotManage(c) + if (refused) return refused + if (!isAdmin(access.role)) + return jsonError(c, 403, 'Only org owners and admins can change visibility') + set.public = body.public === true + } + if (body.slug !== undefined && body.slug !== access.collection.slug) { + const err = validateCollectionSlug(body.slug) + if (err) return jsonError(c, 422, err) + const [taken] = await c.var.ports.db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where( + and( + eq(schema.collections.organizationId, access.owner.id), + eq(schema.collections.slug, body.slug as string), + ), + ) + .limit(1) + if (taken) return jsonError(c, 409, 'Collection already exists') + set.slug = body.slug as string + } + if (Object.keys(set).length > 0) { + await c.var.ports.db + .update(schema.collections) + .set({ ...set, updatedAt: new Date() }) + .where(eq(schema.collections.id, access.collection.id)) + // collection.json carries the name, slug and visibility (spec 11.1). + await c.var.ports.jobs.enqueue({ + type: 'collection.info', + collectionId: access.collection.id, + }) + } + return c.json({ ok: true, slug: set.slug ?? access.collection.slug }) + }) + + app.delete('/api/collections/:owner/:slug', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const refused = keyCannotManage(c) + if (refused) return refused + if (!isAdmin(access.role)) return jsonError(c, 403, 'Forbidden') + // Rows go (cascading to versions, sessions, placements, webhooks). Repository + // objects stay until garbage collection exists (edge-redesign.md, Storage layout). + // The tombstone keeps the counters the deletion zeroes, and tells reference-log + // compaction to drop the collection's events. + const { db } = c.var.ports + const col = access.collection + const [totals] = await db + .select({ n: count(), bytes: sql`coalesce(sum(${schema.versions.totalBytes}), 0)` }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, col.id)) + await db.batch([ + db + .insert(schema.collectionTombstones) + .values({ + collectionId: col.id, + organizationId: col.organizationId, + slug: col.slug, + refEvents: col.refEvents, + refBytes: col.refBytes, + versions: totals?.n ?? 0, + totalBytes: Number(totals?.bytes ?? 0), + deletedBy: c.var.principal?.userId ?? null, + }) + .onConflictDoUpdate({ + target: schema.collectionTombstones.collectionId, + set: { + slug: col.slug, + refEvents: col.refEvents, + refBytes: col.refBytes, + deletedAt: new Date(), + }, + }), + db.delete(schema.collections).where(eq(schema.collections.id, col.id)), + ]) + return c.json({ ok: true }) + }) + + app.post('/api/collections/:owner/:slug/transfer', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const refused = keyCannotManage(c) + if (refused) return refused + if (!isAdmin(access.role)) return jsonError(c, 403, 'Forbidden') + const body = (await c.req.json().catch(() => ({}))) as { targetOrgSlug?: unknown } + if (typeof body.targetOrgSlug !== 'string') + return jsonError(c, 400, '"targetOrgSlug" is required') + const target = await membership(c, body.targetOrgSlug) + if (!target.org) return jsonError(c, 404, 'Target org not found') + if (!isAdmin(target.role)) + return jsonError(c, 403, 'You must be an owner or admin of the target org') + const [taken] = await c.var.ports.db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where( + and( + eq(schema.collections.organizationId, target.org.id), + eq(schema.collections.slug, access.collection.slug), + ), + ) + .limit(1) + if (taken) return jsonError(c, 409, 'The target org already has a collection with this slug') + await c.var.ports.db + .update(schema.collections) + .set({ organizationId: target.org.id, updatedAt: new Date() }) + .where(eq(schema.collections.id, access.collection.id)) + await c.var.ports.jobs.enqueue({ type: 'collection.info', collectionId: access.collection.id }) + return c.json({ ok: true, newOwner: target.org.slug }) + }) + + app.post('/api/collections/:owner/:slug/fork', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + if (!c.var.principal) return jsonError(c, 401, 'Authentication required') + const body = await readJson(c, 64 * 1024) + if (body instanceof Response) return body + if (typeof body.targetOrg !== 'string') return jsonError(c, 400, '"targetOrg" is required') + const target = await membership(c, body.targetOrg) + if (!target.org) return jsonError(c, 404, 'Target org not found') + if (!target.role) return jsonError(c, 403, 'Forbidden') + const slug = typeof body.slug === 'string' ? body.slug : access.collection.slug + const err = validateCollectionSlug(slug) + if (err) return jsonError(c, 422, err) + const head = access.collection.headVersionId + if (!head) return jsonError(c, 422, 'Nothing to fork: the collection has no versions') + const [version] = await c.var.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, head)) + const [taken] = await c.var.ports.db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where( + and( + eq(schema.collections.organizationId, target.org.id), + eq(schema.collections.slug, slug), + ), + ) + .limit(1) + if (taken) return jsonError(c, 409, 'Collection already exists') + const forked = await forkCollection( + c.var.ports, + { collection: access.collection, version: version! }, + { organizationId: target.org.id, slug, name: access.collection.name, public: false }, + access.isMember, + ) + return c.json( + { + id: forked.collection.id, + owner: target.org.slug, + slug, + name: forked.collection.name, + forkedFrom: { + owner: access.owner.slug, + slug: access.collection.slug, + version: version!.semver, + }, + version: { semver: forked.version.semver, recordCount: forked.version.recordCount }, + }, + 201, + ) + }) + + app.post('/api/collections/:owner/:slug/metadata', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + // Metadata goes into the version root, so it follows the input rules. + const patch = await readJson(c, MAX_METADATA_BODY) + if (patch instanceof Response) return patch + const ports = c.var.ports + const base = await headBase(ports, access.collection.id) + if (!base) return jsonError(c, 422, 'No versions exist yet') + const repo = await ports.stores.forCollection(access.collection.id) + const root = await repo.root(base.hash) + const priv = root.private ? await repo.privateSet(root.private) : null + // A patch over the current metadata; null clears a field. + const merged: Record = { ...(root.metadata ?? {}), ...patch } + for (const [k, v] of Object.entries(merged)) if (v === null) delete merged[k] + const metadata = Object.keys(merged).length ? merged : null + if (JSON.stringify(metadata) === JSON.stringify(root.metadata ?? null)) + return c.json({ semver: base.semver, unchanged: true }) + const hashes = new Map() + for (const [slug, t] of Object.entries(priv?.types ?? {})) hashes.set(slug, t.schema) + for (const [slug, t] of Object.entries(root.public.types)) hashes.set(slug, t.schema) + const types = await Promise.all( + [...hashes].map(async ([slug, h]) => ({ + slug, + schema: await repo.schema(h), + schemaHash: h, + public: null, + private: null, + })), + ) + // No change streams, so a commit a deletion window interrupted simply runs again. + const r = await fenced(ports.db, (fence) => + commitVersion(ports, { + collectionId: access.collection.id, + fence, + base, + types, + metadata, + message: 'Metadata update', + pushedBy: c.var.principal?.userId ?? null, + }), + ) + if (r.status === 'conflict') return jsonError(c, 409, 'Version conflict') + if (r.status !== 'committed') return c.json({ semver: base.semver, unchanged: true }) + return c.json({ semver: r.version.semver, hash: r.version.hash, status: 'completed' }, 201) + }) + + return app +} diff --git a/packages/server/src/api/push.ts b/packages/server/src/api/push.ts new file mode 100644 index 0000000..5b708d7 --- /dev/null +++ b/packages/server/src/api/push.ts @@ -0,0 +1,346 @@ +/** + * Push routes, mounted at /api/collections. + * + * Delta push, the protocol's one way to publish (docs/protocol-v2.md, section 11.4): + * POST /:owner/:slug/push open a session against a base + * POST /:owner/:slug/push/:sid/records NDJSON {id,type,data,private?} + * POST /:owner/:slug/push/:sid/deletes NDJSON {type,id} + * POST /:owner/:slug/push/:sid/commit ?async=true for a job + * GET /:owner/:slug/push/:sid status (and result after an async commit) + * DELETE /:owner/:slug/push/:sid abandon + */ +import { hashSchema, parseSemver } from '@underlay/protocol' +import { and, eq, isNull } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import * as schema from '../db/schema.js' +import { SMALL_UPLOAD_BYTES } from '../files/files.js' +import { registerJob } from '../jobs.js' +import type { Ports } from '../ports.js' +import { + headBase, + ingestDeletes, + ingestRecords, + MAX_BATCH_BYTES, + MAX_BATCH_LINES, +} from '../push/delta.js' +import { finalizeSession } from '../push/finalize.js' +import { + createSession, + getSession, + limits, + loadInputs, + schemaSetError, + SESSION_TTL_MS, + type SessionInputs, + type SessionRow, + transition, +} from '../push/session.js' +import { collectionFileSizes } from '../versions/file-refs.js' +import { type CollectionAccess, jsonError, requireCollection } from './access.js' +import { BodyTooLarge, readJson, readLines, readText } from './body.js' + +const MAX_OPEN_BYTES = 8 * 1024 * 1024 +/** + * Commits run as a job even without ?async above this many uploaded records, + * or base records whose type's schema changes (they're all revalidated). + */ +export const commitConfig = { asyncAbove: 100_000 } + +/** + * This node's push limits, advertised when a session opens (docs/protocol-v2.md, + * section 11.4). Read on each call: tests lower some of them. + */ +export const pushLimits = () => ({ + open_bytes: MAX_OPEN_BYTES, + batch_bytes: MAX_BATCH_BYTES, + batch_lines: MAX_BATCH_LINES, + session_idle_seconds: SESSION_TTL_MS / 1000, + open_sessions: limits.openSessions, + file_bytes: SMALL_UPLOAD_BYTES, +}) + +registerJob('push.commit', async (job, ports) => { + await finalizeSession(ports, String(job.sessionId)) +}) + +async function readNdjson(c: Context): Promise { + try { + return await readText(c, MAX_BATCH_BYTES) + } catch (err) { + if (err instanceof BodyTooLarge) + return jsonError(c, 413, `Batches are limited to ${MAX_BATCH_BYTES} bytes`) + throw err + } +} + +const str = (v: unknown): string | null => (typeof v === 'string' ? v : null) + +const isPlainObject = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v) + +/** The session named in the URL, if it belongs to this collection and caller. */ +async function ownSession( + c: Context, + access: CollectionAccess, +): Promise { + const session = await getSession(c.var.ports, c.req.param('sid') ?? '') + if (!session || session.collectionId !== access.collection.id) { + return jsonError(c, 404, 'Session not found') + } + if (session.userId !== c.var.principal?.userId) return jsonError(c, 403, 'Not your session') + return session +} + +/** The schemas and metadata of a base version (for sessions that keep them). */ +async function baseInputs(c: Context, collectionId: string, baseHash: string | null) { + if (!baseHash) return { schemas: {}, metadata: null } + const repo = await c.var.ports.stores.forCollection(collectionId) + const root = await repo.root(baseHash) + const priv = root.private ? await repo.privateSet(root.private) : null + const hashes = new Map() + for (const [slug, t] of Object.entries(priv?.types ?? {})) hashes.set(slug, t.schema) + for (const [slug, t] of Object.entries(root.public.types)) hashes.set(slug, t.schema) + const schemas: Record> = {} + for (const [slug, h] of hashes) schemas[slug] = await repo.schema(h) + return { schemas, metadata: root.metadata } +} + +/** Check a requested base semver against the head. Null/undefined means "no check". */ +function baseConflict(requested: unknown, head: { semver: string } | null): boolean { + if (requested === null || requested === undefined) return false + if (typeof requested !== 'string') return true + return parseSemver(requested).semver !== (head?.semver ?? null) +} + +async function commitRoute(c: Context, session: SessionRow) { + const ports = c.var.ports + const body = await readJson(c, 64 * 1024) + if (body instanceof Response) return body + const wantsAsync = + ['true', '1'].includes(c.req.query('async') ?? '') || + body.async === true || + session.recordsReceived > commitConfig.asyncAbove || + (await revalidates(ports, session)) > commitConfig.asyncAbove + if ( + // The error of a commit refused for missing files is cleared on the next try. + !(await transition(ports, session.id, 'open', 'committing', { + finalizeStartedAt: new Date(), + error: null, + })) + ) { + const now = await getSession(ports, session.id) + if (now?.status === 'committed') return c.json(now.result ?? {}, 201) + return jsonError(c, 409, `Session is ${now?.status ?? 'gone'}`) + } + if (wantsAsync) { + await ports.jobs.enqueue({ type: 'push.commit', sessionId: session.id }) + return c.json( + { + session_id: session.id, + status: 'committing', + poll: `GET ${new URL(c.req.url).pathname.replace(/\/commit$/, '')}`, + }, + 202, + ) + } + const outcome = await finalizeSession(ports, session.id) + return c.json(outcome.body, outcome.status) +} + +/** + * Records of the base the commit has to revalidate: those of every type whose + * schema the session changes. A small push that changes a large type's schema + * is O(type size), so it goes to the async path too. + */ +async function revalidates(ports: Ports, session: SessionRow): Promise { + if (!session.baseVersionId) return 0 + const [base] = await ports.db + .select({ typeCounts: schema.versions.typeCounts }) + .from(schema.versions) + .where(eq(schema.versions.id, session.baseVersionId)) + if (!base) return 0 + const open = await ports.db + .select({ slug: schema.schemaUsage.typeSlug, hash: schema.schemaUsage.schemaHash }) + .from(schema.schemaUsage) + .where( + and( + eq(schema.schemaUsage.collectionId, session.collectionId), + isNull(schema.schemaUsage.toSeq), + ), + ) + const was = new Map(open.map((u) => [u.slug, u.hash])) + const inputs = await loadInputs(ports, session.id) + let n = 0 + for (const [slug, body] of Object.entries(inputs.schemas)) { + const before = was.get(slug) + if (before && before !== hashSchema(body)) n += base.typeCounts[slug] ?? 0 + } + return n +} + +function statusBody(s: SessionRow) { + return { + session_id: s.id, + status: s.status, + records_received: s.recordsReceived, + expires_at: s.expiresAt, + created_at: s.createdAt, + finalize_started_at: s.finalizeStartedAt, + result: s.result ?? null, + error: s.error ?? null, + } +} + +async function abandon(c: Context, session: SessionRow) { + await transition(c.var.ports, session.id, 'open', 'expired') + return c.json({ ok: true }) +} + +/** + * Declared files the caller must upload: those not held for the collection, by + * the same test the commit applies (collectionFileSizes), so a file listed here + * is exactly one the commit would refuse without an upload. + */ +async function neededFiles( + c: Context, + access: CollectionAccess, + baseHash: string | null, + files: string[], +): Promise { + const wanted = [...new Set(files)] + if (wanted.length === 0) return [] + const repo = await c.var.ports.stores.forCollection(access.collection.id) + const held = await collectionFileSizes(c.var.ports.db, repo, access.collection, baseHash, wanted) + return wanted.filter((h) => !held.has(h)) +} + +export function pushRoutes() { + const app = new Hono() + + // --- Delta push --------------------------------------------------------------- + + app.post('/:owner/:slug/push', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const body = await readJson(c, MAX_OPEN_BYTES) + if (body instanceof Response) return body + const ports = c.var.ports + const head = await headBase(ports, access.collection.id) + if (baseConflict(body.base, head)) + return jsonError(c, 409, 'Version conflict', { currentVersion: head?.semver ?? null }) + + if (body.metadata !== undefined && body.metadata !== null && !isPlainObject(body.metadata)) + return jsonError(c, 400, '"metadata" must be an object or null') + if (body.metadata_patch !== undefined && !isPlainObject(body.metadata_patch)) + return jsonError(c, 400, '"metadata_patch" must be an object') + const base = await baseInputs(c, access.collection.id, head?.hash ?? null) + // The whole new type set, including schemas kept from the base: a commit + // refuses any schema that isn't acceptable, so say so now. + const schemas = body.schemas !== undefined ? body.schemas : base.schemas + const schemaErr = schemaSetError(schemas) + if (schemaErr) return jsonError(c, 422, schemaErr) + const patch = body.metadata_patch + const metadata = + body.metadata !== undefined + ? (body.metadata as Record | null) + : patch + ? { ...(base.metadata ?? {}), ...patch } + : base.metadata + const files = (body.files ?? {}) as { add?: unknown; remove?: unknown } + const hexList = (v: unknown) => + Array.isArray(v) + ? v.filter((h): h is string => typeof h === 'string' && /^[0-9a-f]{64}$/.test(h)) + : [] + const inputs: SessionInputs = { + schemas: schemas as SessionInputs['schemas'], + metadata, + files: { add: hexList(files.add), remove: hexList(files.remove) }, + } + const session = await createSession( + ports, + { + collectionId: access.collection.id, + userId: c.var.principal!.userId, + baseVersionId: head?.id ?? null, + baseSemver: head?.semver ?? null, + message: str(body.message), + appId: str(body.app_id), + actorId: str(body.actor_id), + stripUnknownFields: body.strip_unknown_fields === true, + }, + inputs, + ) + return c.json({ + session_id: session.id, + base: head?.semver ?? null, + needed_files: await neededFiles( + c, + access, + head?.hash ?? null, + inputs.files && 'add' in inputs.files ? inputs.files.add : [], + ), + expires_at: session.expiresAt, + limits: pushLimits(), + }) + }) + + app.post('/:owner/:slug/push/:sid/records', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const session = await ownSession(c, access) + if (session instanceof Response) return session + // Parsed line by line as the body arrives (v2-scale-review.md S6). + let r + try { + r = await ingestRecords(c.var.ports, session, readLines(c, MAX_BATCH_BYTES)) + } catch (err) { + if (err instanceof BodyTooLarge) + return jsonError(c, 413, `Batches are limited to ${MAX_BATCH_BYTES} bytes`) + throw err + } + return r.ok + ? c.json({ received: r.received }) + : jsonError(c, r.status, r.error, { validationErrors: r.details, totalErrors: r.total }) + }) + + app.post('/:owner/:slug/push/:sid/deletes', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const session = await ownSession(c, access) + if (session instanceof Response) return session + const text = await readNdjson(c) + if (text instanceof Response) return text + const r = await ingestDeletes(c.var.ports, session, text) + return r.ok + ? c.json({ received: r.received }) + : jsonError(c, r.status, r.error, { validationErrors: r.details, totalErrors: r.total }) + }) + + app.post('/:owner/:slug/push/:sid/commit', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const session = await ownSession(c, access) + if (session instanceof Response) return session + return commitRoute(c, session) + }) + + app.get('/:owner/:slug/push/:sid', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const session = await ownSession(c, access) + if (session instanceof Response) return session + return c.json(statusBody(session)) + }) + + app.delete('/:owner/:slug/push/:sid', async (c) => { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + const session = await ownSession(c, access) + if (session instanceof Response) return session + return abandon(c, session) + }) + + return app +} diff --git a/packages/server/src/api/records.ts b/packages/server/src/api/records.ts new file mode 100644 index 0000000..f9c7e48 --- /dev/null +++ b/packages/server/src/api/records.ts @@ -0,0 +1,305 @@ +/** + * Records and files by hash, through the reference log (v1 shapes). + * + * GET /api/records/:hash/provenance + * POST /api/records/batch {hashes} → NDJSON {id,type,data,hash} + * GET /api/collections/files/:hash 302, from any collection the caller may read + * + * Results are filtered by the caller's access before anything is returned, + * counts included (edge-redesign.md, Security notes): a presence counts only if + * it is in a public collection's public set, or in a collection of the caller's + * orgs. + */ +import { and, eq, gte, inArray, lt, lte, or, sql } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import { meter } from '../billing/usage.js' +import { chunks } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import { cleanHash, isHash, presignDownload } from '../files/files.js' +import { deniedHashes, isDenied } from '../lib/limits.js' +import { eventsFor, type Presence, presenceOf } from '../refs/log.js' +import { fileSizes } from '../versions/file-refs.js' +import { getRecord, loadView } from '../versions/view.js' +import { jsonError } from './access.js' + +/** Hashes one batch request may ask for: each costs a few queries, and D1 allows 1,000. */ +export const MAX_BATCH_HASHES = 100 +/** Presences a provenance answer lists versions for. */ +const MAX_PROVENANCE_PRESENCES = 300 +/** Versions listed per collection in a provenance answer. */ +const VERSIONS_PER_COLLECTION = 100 + +type Item = { + p: Presence + c: typeof schema.collections.$inferSelect + owner: typeof schema.organization.$inferSelect + member: boolean +} + +async function memberOrgs(c: Context): Promise> { + const p = c.var.principal + if (!p || p.collectionIds) return new Set() + if (p.orgId) return new Set([p.orgId]) + const rows = await c.var.ports.db + .select({ id: schema.member.organizationId }) + .from(schema.member) + .where(eq(schema.member.userId, p.userId)) + return new Set(rows.map((r) => r.id)) +} + +/** + * Presences the caller may see, with their collections. `orgs` is the caller's + * orgs, looked up once per request; `cols` caches collection rows across hashes. + */ +async function visiblePresence( + c: Context, + hash: string, + orgs: Set, + cols = new Map(), +) { + const { db } = c.var.ports + const presence = await presenceOf(c.var.ports, hash) + const missing = [...new Set(presence.map((p) => p.collectionId))].filter((id) => !cols.has(id)) + for (const part of chunks(missing)) { + const rows = await db + .select({ c: schema.collections, owner: schema.organization }) + .from(schema.collections) + .innerJoin(schema.organization, eq(schema.organization.id, schema.collections.organizationId)) + .where(inArray(schema.collections.id, part)) + for (const r of rows) cols.set(r.c.id, r) + } + const items: Item[] = presence.flatMap((p) => { + const col = cols.get(p.collectionId) + if (!col) return [] + const member = orgs.has(col.c.organizationId) + const visible = member || (col.c.public && p.set === 'public') + return visible ? [{ p, c: col.c, owner: col.owner, member }] : [] + }) + return items +} + +/** + * The versions each presence covers, up to VERSIONS_PER_COLLECTION per + * collection: one query per 30 presences (3 bound parameters each), not one per + * presence. + */ +async function versionsOf(c: Context, items: Item[]) { + const { db } = c.var.ports + const v = schema.versions + const out: { collectionId: string; semver: string; createdAt: Date }[] = [] + for (const part of chunks(items.slice(0, MAX_PROVENANCE_PRESENCES), 30)) { + const rn = sql`row_number() OVER (PARTITION BY ${v.collectionId} ORDER BY ${v.seq})`.as( + 'rn', + ) + const sub = db + .select({ collectionId: v.collectionId, semver: v.semver, createdAt: v.createdAt, rn }) + .from(v) + .where( + or( + ...part.map((i) => + and( + eq(v.collectionId, i.c.id), + gte(v.seq, i.p.from), + i.p.to === null ? undefined : lt(v.seq, i.p.to), + ), + ), + ), + ) + .as('sub') + out.push( + ...(await db + .select({ collectionId: sub.collectionId, semver: sub.semver, createdAt: sub.createdAt }) + .from(sub) + .where(lte(sub.rn, VERSIONS_PER_COLLECTION))), + ) + } + return out +} + +/** + * The record body at a version where it was present, only if it is the record + * asked for: the log can lag the head, and an id can hold another record there. + */ +async function bodyAt( + c: Context, + item: { p: Presence; c: typeof schema.collections.$inferSelect; member: boolean }, + hash: string, +) { + const seq = item.p.to === null ? null : item.p.to - 1 + const [v] = + seq === null + ? await c.var.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, item.c.headVersionId ?? '')) + : await c.var.ports.db + .select() + .from(schema.versions) + .where(and(eq(schema.versions.collectionId, item.c.id), eq(schema.versions.seq, seq))) + if (!v) return null + const repo = await c.var.ports.stores.forCollection(item.c.id) + const view = await loadView(repo, v, item.member, await deniedHashes(c.var.ports.db)) + const t = view.types.find((x) => x.slug === item.p.type) + const rec = t ? await getRecord(view, t, item.p.id) : null + return rec?.hash === hash ? rec : null +} + +/** The body from the first of these presences that still has it. */ +async function firstBody(c: Context, items: Item[], hash: string) { + for (const item of items) { + const rec = await bodyAt(c, item, hash) + if (rec) return rec + } + return null +} + +export function recordRoutes() { + const app = new Hono() + + app.get('/api/records/:hash/provenance', async (c) => { + const hash = c.req.param('hash').replace(/^sha256:/, '') + const items = await visiblePresence(c, hash, await memberOrgs(c)) + const records = items.filter((i) => i.p.kind === 'r') + if (records.length === 0) return jsonError(c, 404, 'Record not found') + const rec = await firstBody(c, records, hash) + if (!rec) return jsonError(c, 404, 'Record not found') + const parsed = JSON.parse(rec.body!) as { id: string; type: string; data: unknown } + const byId = new Map(records.map((i) => [i.c.id, i])) + const seen = new Set() + const references: { + owner: string + collection: string + collectionName: string + semver: string + versionCreatedAt: Date + }[] = [] + for (const v of await versionsOf(c, records)) { + // A collection can hold the record in both sets at once (a move between them). + const key = `${v.collectionId}\u0000${v.semver}` + if (seen.has(key)) continue + seen.add(key) + const i = byId.get(v.collectionId)! + references.push({ + owner: i.owner.slug, + collection: i.c.slug, + collectionName: i.c.name, + semver: v.semver, + versionCreatedAt: v.createdAt, + }) + } + references.sort((a, b) => a.versionCreatedAt.getTime() - b.versionCreatedAt.getTime()) + return c.json({ + hash, + recordHash: hash, + recordId: parsed.id, + type: parsed.type, + data: parsed.data, + size: rec.size, + firstSeen: references[0]?.versionCreatedAt ?? null, + createdAt: references[0]?.versionCreatedAt ?? null, + references, + }) + }) + + /** + * Where a record or file first appeared, among the collections the caller may + * read: the earliest "+" event (edge-redesign.md, Provenance: the cheap + * answer most callers want). It reads only that hash's events and the versions + * they name, with no interval folding or fork expansion. + */ + app.get('/api/records/:hash/first', async (c) => { + const hash = c.req.param('hash').replace(/^sha256:/, '') + const { db } = c.var.ports + const adds = (await eventsFor(c.var.ports, hash)).filter((e) => e[5] === '+') + if (adds.length === 0) return jsonError(c, 404, 'Not found') + const orgs = await memberOrgs(c) + const cols = new Map() + for (const part of chunks([...new Set(adds.map((e) => e[2]))])) { + const rows = await db + .select({ c: schema.collections, owner: schema.organization }) + .from(schema.collections) + .innerJoin( + schema.organization, + eq(schema.organization.id, schema.collections.organizationId), + ) + .where(inArray(schema.collections.id, part)) + for (const r of rows) cols.set(r.c.id, r) + } + const visible = adds.filter((e) => { + const col = cols.get(e[2]) + return !!col && (orgs.has(col.c.organizationId) || (col.c.public && e[3] === 'public')) + }) + // The earliest of the versions the visible additions name, in one query. + if (visible.length === 0) return jsonError(c, 404, 'Not found') + const pairs = JSON.stringify(visible.map((e) => [e[2], e[4]])) + const [earliest] = (await db.all(sql` + SELECT v.id AS id FROM ${schema.versions} v + JOIN json_each(${pairs}) p + ON v.collection_id = json_extract(p.value, '$[0]') AND v.seq = json_extract(p.value, '$[1]') + ORDER BY v.created_at, v.seq LIMIT 1 + `)) as { id: string }[] + const [v] = earliest + ? await db.select().from(schema.versions).where(eq(schema.versions.id, earliest.id)) + : [] + const e = v && visible.find((x) => x[2] === v.collectionId && x[4] === v.seq) + const first = v && e ? { e, v } : null + if (!first) return jsonError(c, 404, 'Not found') + const col = cols.get(first.e[2])! + return c.json({ + hash, + kind: first.e[1] === 'r' ? 'record' : 'file', + owner: col.owner.slug, + collection: col.c.slug, + semver: first.v.semver, + createdAt: first.v.createdAt, + ...(first.e[1] === 'r' ? { type: first.e[6], id: first.e[7] } : {}), + }) + }) + + app.post('/api/records/batch', async (c) => { + const body = (await c.req.json().catch(() => null)) as { hashes?: unknown } | null + const hashes = Array.isArray(body?.hashes) + ? body.hashes.filter((h): h is string => typeof h === 'string') + : null + if (!hashes || hashes.length === 0 || hashes.length > MAX_BATCH_HASHES) + return jsonError(c, 400, `"hashes" must be 1–${MAX_BATCH_HASHES} record hashes`) + const orgs = await memberOrgs(c) + const cols = new Map() + const lines: string[] = [] + for (const h of hashes) { + const hash = h.replace(/^sha256:/, '') + const items = await visiblePresence(c, hash, orgs, cols) + const rec = await firstBody( + c, + items.filter((i) => i.p.kind === 'r'), + hash, + ) + if (rec) lines.push(`${rec.body!.slice(0, -1)},"hash":"${rec.hash}"}`) + } + return new Response(lines.length ? lines.join('\n') + '\n' : '', { + headers: { 'content-type': 'application/x-ndjson' }, + }) + }) + + app.get('/api/collections/files/:hash', async (c) => { + const hash = cleanHash(c.req.param('hash')) + if (!isHash(hash)) return jsonError(c, 404, 'File not found') + const items = await visiblePresence(c, hash, await memberOrgs(c)) + const found = items.find((i) => i.p.kind === 'f') + if (!found) return jsonError(c, 404, 'File not found') + // After the access check: a 451 would confirm the file exists to anyone. + if (await isDenied(c.var.ports.db, hash)) return jsonError(c, 451, 'This file is unavailable') + const url = await presignDownload(c.var.ports, hash) + if (!url) return jsonError(c, 404, 'File not found') + // Billed to the first collection the caller may read it from. + const on = { id: found.c.id, accountId: found.c.organizationId } + const [size] = (await fileSizes(c.var.ports.db, [hash])).values() + meter(c.var.meter, 'file_downloads', 1, on) + meter(c.var.meter, 'file_bytes', size ?? 0, on) + return c.redirect(url, 302) + }) + + return app +} diff --git a/packages/server/src/api/schemas.ts b/packages/server/src/api/schemas.ts new file mode 100644 index 0000000..ba816cf --- /dev/null +++ b/packages/server/src/api/schemas.ts @@ -0,0 +1,289 @@ +/** + * Schema reads and labels (v1 shapes, with the schema hash as the id). + * + * GET /api/collections/:owner/:slug/schemas?version&raw + * GET /api/schemas?label|slug|schema_hash|q&limit&offset + * GET /api/schemas/:id id = schema hash + * POST /api/schemas/:id/labels {label} + * DELETE /api/schemas/:id/labels/:label stewards only + * + * Visibility comes from schema_usage: a schema is visible when it is used in + * the public set of a public collection, or by a collection in one of the + * caller's orgs. `q` matches labels and type slugs; schema bodies live in the + * repository, not SQLite, so there is no full-text search over them. + */ +import { and, desc, eq, inArray, isNull, or, type SQL, sql } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import { chunks } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import { findVersion, loadView } from '../versions/view.js' +import { jsonError, requireCollection } from './access.js' +import { stewardOnly } from './admin.js' + +const MAX_LABEL_LENGTH = 100 + +/** + * The caller's orgs as a SQL subquery (null for none). A subquery rather than a + * list: D1 binds at most 100 parameters, and a user can belong to more orgs. + */ +function callerOrgs(c: Context): SQL | null { + const p = c.var.principal + if (!p || p.collectionIds) return null + if (p.orgId) return sql`(${p.orgId})` + return sql`(SELECT ${schema.member.organizationId} FROM ${schema.member} WHERE ${schema.member.userId} = ${p.userId})` +} + +/** A SQL condition: this schema hash is visible to the caller. */ +function visibleSchema(hashCol: unknown, orgs: SQL | null) { + return sql`EXISTS ( + SELECT 1 FROM ${schema.schemaUsage} u + JOIN ${schema.collections} c ON c.id = u.collection_id + WHERE u.schema_hash = ${hashCol} + AND ((c.public = 1 AND u."set" = 'public') + ${orgs ? sql`OR c.organization_id IN ${orgs}` : sql``}) + )` +} + +async function labelsFor(c: Context, hashes: string[]) { + if (hashes.length === 0) return new Map() + const rows = [] + for (const part of chunks(hashes)) { + rows.push( + ...(await c.var.ports.db + .select() + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaHash, part))), + ) + } + const out = new Map() + for (const r of rows) { + if (!out.has(r.schemaHash)) out.set(r.schemaHash, []) + out.get(r.schemaHash)!.push({ label: r.label, createdAt: r.createdAt }) + } + return out +} + +/** Schema bodies come from a repository that holds them: any collection using the schema. */ +async function schemaBody( + c: Context, + hash: string, +): Promise | null> { + const [u] = await c.var.ports.db + .select({ collectionId: schema.schemaUsage.collectionId }) + .from(schema.schemaUsage) + .where(eq(schema.schemaUsage.schemaHash, hash)) + .limit(1) + if (!u) return null + const repo = await c.var.ports.stores.forCollection(u.collectionId) + return repo.schema(hash) +} + +export function schemaRoutes() { + const app = new Hono() + + app.get('/api/collections/:owner/:slug/schemas', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const v = await findVersion( + c.var.ports.db, + access.collection.id, + c.req.query('version') ?? 'latest', + access.collection.headVersionId, + ) + if (!v) return jsonError(c, 404, 'No versions found') + const repo = await c.var.ports.stores.forCollection(access.collection.id) + const view = await loadView(repo, v, access.isMember) + const raw = c.req.query('raw') === 'true' + const labels = raw + ? new Map() + : await labelsFor( + c, + view.types.map((t) => t.schemaHash), + ) + const schemas = await Promise.all( + view.types.map(async (t) => { + const body = await repo.schema(t.schemaHash) + const l = labels.get(t.schemaHash) + return { + slug: t.slug, + schemaId: t.schemaHash, + schemaHash: t.schemaHash, + schema: l?.length + ? { ...body, 'x-underlay-labels': l.map((x: { label: string }) => x.label) } + : body, + } + }), + ) + return c.json({ version: v.semver, semver: v.semver, schemas }) + }) + + app.get('/api/schemas', async (c) => { + const orgs = callerOrgs(c) + const limit = Math.min(100, Math.max(1, Number(c.req.query('limit') ?? 50) || 50)) + const offset = Math.max(0, Number(c.req.query('offset') ?? 0) || 0) + const { db } = c.var.ports + const one = c.req.query('schema_hash') + if (one) { + const [row] = await db + .select() + .from(schema.schemas) + .where(and(eq(schema.schemas.hash, one), visibleSchema(schema.schemas.hash, orgs))) + if (!row) return jsonError(c, 404, 'Schema not found') + // Only the uses this caller can see: a private collection's isn't counted for others. + const [usage] = (await db.all(sql` + SELECT count(DISTINCT u.collection_id) AS n FROM ${schema.schemaUsage} u + JOIN ${schema.collections} c ON c.id = u.collection_id + WHERE u.schema_hash = ${one} + AND ((c.public = 1 AND u."set" = 'public') + ${orgs ? sql`OR c.organization_id IN ${orgs}` : sql``}) + `)) as { n: number }[] + const labels = (await labelsFor(c, [one])).get(one) ?? [] + return c.json({ + id: one, + schemaHash: one, + schema: await schemaBody(c, one), + createdAt: row.createdAt, + labels: labels.map((l) => l.label), + usageCount: usage?.n ?? 0, + }) + } + const label = c.req.query('label') + const slug = c.req.query('slug') + const q = c.req.query('q') + const conds = [visibleSchema(schema.schemas.hash, orgs)] + if (label) + conds.push( + sql`EXISTS (SELECT 1 FROM ${schema.schemaLabels} l WHERE l.schema_hash = ${schema.schemas.hash} AND l.label LIKE ${`%${label}%`})`, + ) + if (slug) + conds.push( + sql`EXISTS (SELECT 1 FROM ${schema.schemaUsage} u WHERE u.schema_hash = ${schema.schemas.hash} AND u.type_slug = ${slug})`, + ) + if (q) { + conds.push( + or( + sql`EXISTS (SELECT 1 FROM ${schema.schemaLabels} l WHERE l.schema_hash = ${schema.schemas.hash} AND l.label LIKE ${`%${q}%`})`, + sql`EXISTS (SELECT 1 FROM ${schema.schemaUsage} u WHERE u.schema_hash = ${schema.schemas.hash} AND u.type_slug LIKE ${`%${q}%`})`, + )!, + ) + } + const rows = await db + .select() + .from(schema.schemas) + .where(and(...conds)) + .orderBy(desc(schema.schemas.createdAt)) + .limit(limit) + .offset(offset) + const labels = await labelsFor( + c, + rows.map((r) => r.hash), + ) + const out = await Promise.all( + rows.map(async (r) => ({ + id: r.hash, + schemaHash: r.hash, + schema: await schemaBody(c, r.hash), + createdAt: r.createdAt, + labels: (labels.get(r.hash) ?? []).map((l) => l.label), + })), + ) + return c.json(out) + }) + + app.get('/api/schemas/:id', async (c) => { + const orgs = callerOrgs(c) + const hash = c.req.param('id') + const { db } = c.var.ports + const [row] = await db + .select() + .from(schema.schemas) + .where(and(eq(schema.schemas.hash, hash), visibleSchema(schema.schemas.hash, orgs))) + if (!row) return jsonError(c, 404, 'Schema not found') + const usage = await db + .select({ + slug: schema.schemaUsage.typeSlug, + owner: schema.organization.slug, + collection: schema.collections.slug, + headSemver: schema.versions.semver, + }) + .from(schema.schemaUsage) + .innerJoin(schema.collections, eq(schema.collections.id, schema.schemaUsage.collectionId)) + .innerJoin(schema.organization, eq(schema.organization.id, schema.collections.organizationId)) + .leftJoin(schema.versions, eq(schema.versions.id, schema.collections.headVersionId)) + .where( + and( + eq(schema.schemaUsage.schemaHash, hash), + isNull(schema.schemaUsage.toSeq), + // Public use for everyone; members also see their own orgs' private use. + orgs + ? or( + and(eq(schema.collections.public, true), eq(schema.schemaUsage.set, 'public')), + sql`${schema.collections.organizationId} IN ${orgs}`, + ) + : and(eq(schema.collections.public, true), eq(schema.schemaUsage.set, 'public')), + ), + ) + .limit(50) + return c.json({ + id: hash, + schemaHash: hash, + schema: await schemaBody(c, hash), + createdAt: row.createdAt, + labels: (await labelsFor(c, [hash])).get(hash) ?? [], + // A type used in both sets is one entry. + usage: [ + ...new Map( + usage.map((u) => [ + `${u.owner}/${u.collection}\u0000${u.slug}`, + { slug: u.slug, semver: u.headSemver, collection: `${u.owner}/${u.collection}` }, + ]), + ).values(), + ], + }) + }) + + app.post('/api/schemas/:id/labels', async (c) => { + const p = c.var.principal + if (!p) return jsonError(c, 401, 'Authentication required') + if (p.scope === 'read') return jsonError(c, 403, 'Write access required') + const hash = c.req.param('id') + const body = (await c.req.json().catch(() => ({}))) as { label?: unknown } + const label = typeof body.label === 'string' ? body.label.trim() : '' + if (!label) return jsonError(c, 400, 'Label is required') + if (label.length > MAX_LABEL_LENGTH) + return jsonError(c, 400, `Label must be at most ${MAX_LABEL_LENGTH} characters`) + const [row] = await c.var.ports.db + .select() + .from(schema.schemas) + .where(and(eq(schema.schemas.hash, hash), visibleSchema(schema.schemas.hash, callerOrgs(c)))) + if (!row) return jsonError(c, 404, 'Schema not found') + const inserted = await c.var.ports.db + .insert(schema.schemaLabels) + .values({ schemaHash: hash, label }) + .onConflictDoNothing() + .returning() + return inserted.length + ? c.json({ status: 'created', schemaId: hash, label }, 201) + : c.json({ status: 'exists', schemaId: hash, label }) + }) + + app.delete('/api/schemas/:id/labels/:label', async (c) => { + // Labels are shared by every collection using the schema: removing one is a + // steward's call (a session, or an unscoped non-read key held by a steward). + const denied = await stewardOnly(c) + if (denied) return denied + await c.var.ports.db + .delete(schema.schemaLabels) + .where( + and( + eq(schema.schemaLabels.schemaHash, c.req.param('id')), + eq(schema.schemaLabels.label, c.req.param('label')), + ), + ) + return c.json({ ok: true }) + }) + + return app +} diff --git a/packages/server/src/api/stats.ts b/packages/server/src/api/stats.ts new file mode 100644 index 0000000..90d57d2 --- /dev/null +++ b/packages/server/src/api/stats.ts @@ -0,0 +1,500 @@ +/** + * Instance metrics for the steward admin pages. Stewards only. + * + * GET /api/admin/stats/overview?days= totals, the period's usage by day, what needs attention + * GET /api/admin/stats/orgs?days= per organization: size, history, usage in the period + * GET /api/admin/stats/orgs/:slug?days= one organization: per collection, and usage by day + * GET /api/admin/stats/corpus types, shared schemas, largest collections, growth + * GET /api/admin/stats/billing?month= metered quantities per organization for a month, + * reconcile health and deletions + * GET /api/admin/stats/operations push sessions, storage locations, uploads, jobs + * + * Sizes are logical: a version's `total_bytes` is its records and files as the + * root counts them, so history bytes count shared content once per version + * (edge-redesign.md, "Metering": bill on deterministic logical measures). The + * physical store is the `files` table's unique bytes plus the repositories. + * `days` is 1–365 (default 30), counted in UTC days ending today. + */ +import { type SQL, sql } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import type { UsageMetric } from '../billing/usage.js' +import { jsonError } from './access.js' +import { stewardOnly } from './admin.js' + +const METRICS: UsageMetric[] = ['api_calls', 'response_bytes', 'file_downloads', 'file_bytes'] +type Usage = Record + +const emptyUsage = (): Usage => ({ + api_calls: 0, + response_bytes: 0, + file_downloads: 0, + file_bytes: 0, +}) + +const n = (v: unknown) => Number(v ?? 0) +const isoDay = (t: number) => new Date(t).toISOString().slice(0, 10) +const DAY_MS = 86_400_000 + +/** The period's first day and every day in it (UTC, oldest first). */ +function period(c: Context): { since: string; days: string[] } { + const raw = Number(c.req.query('days') ?? 30) + const count = Number.isInteger(raw) ? Math.min(365, Math.max(1, raw)) : 30 + const today = Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate(), + ) + const days = Array.from({ length: count }, (_, i) => isoDay(today - (count - 1 - i) * DAY_MS)) + return { since: days[0]!, days } +} + +async function rows(c: Context, query: SQL): Promise { + return (await c.var.ports.db.all(query)) as T[] +} + +/** Usage summed by `key` (a column of usage_rollups) over days matching `where`. */ +async function usageBy( + c: Context, + key: 'account_id' | 'collection_id' | 'day', + where: SQL, +): Promise> { + const out = new Map() + const found = await rows<{ k: string; metric: UsageMetric; amount: number }>( + c, + sql`SELECT ${sql.raw(key)} AS k, metric, sum(amount) AS amount + FROM usage_rollups WHERE ${where} GROUP BY ${sql.raw(key)}, metric`, + ) + for (const r of found) { + if (!METRICS.includes(r.metric)) continue + const u = out.get(r.k) ?? emptyUsage() + u[r.metric] += n(r.amount) + out.set(r.k, u) + } + return out +} + +/** Per live collection: its owner, head version figures, and history totals. */ +interface CollectionFigures { + id: string + slug: string + name: string + orgId: string + public: boolean + records: number + publicRecords: number + files: number + latestBytes: number + versions: number + historyBytes: number + refEvents: number + refBytes: number + lastPushAt: number | null + reconciledAt: number | null + corrections: number +} + +async function collectionFigures(c: Context, orgId?: string) { + const found = await rows>( + c, + sql`SELECT c.id, c.slug, c.name, c.organization_id AS orgId, c.public, + coalesce(v.record_count, 0) AS records, + coalesce(v.public_record_count, 0) AS publicRecords, + coalesce(v.file_count, 0) AS files, + coalesce(v.total_bytes, 0) AS latestBytes, + c.version_count AS versions, + c.history_bytes AS historyBytes, + c.ref_events AS refEvents, c.ref_bytes AS refBytes, + c.last_push_at AS lastPushAt, c.reconciled_at AS reconciledAt, + coalesce(json_array_length(c.reconcile_report), 0) AS corrections + FROM collections c + LEFT JOIN versions v ON v.id = c.head_version_id + WHERE c.deleted_at IS NULL ${orgId ? sql`AND c.organization_id = ${orgId}` : sql``}`, + ) + return found.map((r): CollectionFigures => ({ + id: String(r.id), + slug: String(r.slug), + name: String(r.name), + orgId: String(r.orgId), + public: !!r.public, + records: n(r.records), + publicRecords: n(r.publicRecords), + files: n(r.files), + latestBytes: n(r.latestBytes), + versions: n(r.versions), + historyBytes: n(r.historyBytes), + refEvents: n(r.refEvents), + refBytes: n(r.refBytes), + lastPushAt: r.lastPushAt == null ? null : n(r.lastPushAt), + reconciledAt: r.reconciledAt == null ? null : n(r.reconciledAt), + corrections: n(r.corrections), + })) +} + +const SUMMED = [ + 'records', + 'publicRecords', + 'files', + 'latestBytes', + 'versions', + 'historyBytes', + 'refEvents', + 'refBytes', +] as const +type Sums = Record<(typeof SUMMED)[number], number> + +function sums(cols: CollectionFigures[]): Sums { + const out = Object.fromEntries(SUMMED.map((k) => [k, 0])) as Sums + for (const col of cols) for (const k of SUMMED) out[k] += col[k] + return out +} + +/** The organizations, with members and their collections' figures summed; and the collections. */ +async function orgFigures(c: Context) { + const [orgs, members, cols] = await Promise.all([ + rows<{ id: string; slug: string; name: string; isDefault: number; createdAt: number }>( + c, + sql`SELECT id, slug, name, is_default AS isDefault, created_at AS createdAt FROM organization`, + ), + rows<{ orgId: string; n: number }>( + c, + sql`SELECT organization_id AS orgId, count(*) AS n FROM member GROUP BY organization_id`, + ), + collectionFigures(c), + ]) + const memberCount = new Map(members.map((m) => [m.orgId, n(m.n)])) + const byOrg = new Map() + for (const col of cols) byOrg.set(col.orgId, [...(byOrg.get(col.orgId) ?? []), col]) + const perOrg = orgs.map((o) => { + const mine = byOrg.get(o.id) ?? [] + return { + id: o.id, + slug: o.slug, + name: o.name, + personal: !!o.isDefault, + createdAt: n(o.createdAt), + members: memberCount.get(o.id) ?? 0, + collections: mine.length, + publicCollections: mine.filter((col) => col.public).length, + lastPushAt: mine.reduce( + (m, col) => + col.lastPushAt != null && (m == null || col.lastPushAt > m) ? col.lastPushAt : m, + null, + ), + ...sums(mine), + } + }) + return { orgs: perOrg, cols } +} + +export function statsRoutes() { + const app = new Hono() + + app.use('/api/admin/stats/*', async (c, next) => { + const denied = await stewardOnly(c) + return denied ?? next() + }) + + app.get('/api/admin/stats/overview', async (c) => { + const { since, days } = period(c) + const dayAgo = Date.now() - DAY_MS + const [{ orgs, cols }, users, files, schemas, daily, attention] = await Promise.all([ + orgFigures(c), + rows<{ n: number }>(c, sql`SELECT count(*) AS n FROM user`), + rows<{ n: number; bytes: number }>( + c, + sql`SELECT count(*) AS n, coalesce(sum(size), 0) AS bytes FROM files`, + ), + rows<{ n: number }>(c, sql`SELECT count(*) AS n FROM schemas`), + usageBy(c, 'day', sql`day >= ${since}`), + rows>( + c, + sql`SELECT + (SELECT count(*) FROM abuse_reports WHERE status = 'open') AS openReports, + (SELECT count(*) FROM collections WHERE deleted_at IS NULL + AND json_array_length(reconcile_report) > 0) AS corrections, + (SELECT count(*) FROM storage_locations + WHERE kind != 'platform' AND status IN ('broken', 'unverified')) AS locationProblems, + (SELECT count(*) FROM push_sessions + WHERE status = 'failed' AND created_at >= ${dayAgo}) AS failedPushes, + (SELECT count(*) FROM push_sessions WHERE status IN ('open', 'committing')) AS openPushes`, + ), + ]) + const usage = emptyUsage() + for (const u of daily.values()) for (const m of METRICS) usage[m] += u[m] + return c.json({ + since, + totals: { + users: n(users[0]?.n), + orgs: orgs.filter((o) => !o.personal).length, + personalOrgs: orgs.filter((o) => o.personal).length, + collections: cols.length, + publicCollections: cols.filter((col) => col.public).length, + ...sums(cols), + uniqueFiles: n(files[0]?.n), + uniqueFileBytes: n(files[0]?.bytes), + schemas: n(schemas[0]?.n), + }, + usage, + daily: days.map((day) => ({ day, ...(daily.get(day) ?? emptyUsage()) })), + attention: Object.fromEntries(Object.entries(attention[0] ?? {}).map(([k, v]) => [k, n(v)])), + }) + }) + + app.get('/api/admin/stats/orgs', async (c) => { + const { since } = period(c) + const [{ orgs }, usage] = await Promise.all([ + orgFigures(c), + usageBy(c, 'account_id', sql`day >= ${since}`), + ]) + return c.json({ + since, + orgs: orgs.map((o) => ({ ...o, usage: usage.get(o.id) ?? emptyUsage() })), + }) + }) + + app.get('/api/admin/stats/orgs/:slug', async (c) => { + const { since, days } = period(c) + const [org] = await rows<{ id: string; slug: string; name: string; isDefault: number }>( + c, + sql`SELECT id, slug, name, is_default AS isDefault FROM organization + WHERE slug = ${c.req.param('slug')}`, + ) + if (!org) return jsonError(c, 404, 'Organization not found') + const scope = sql`account_id = ${org.id} AND day >= ${since}` + const [cols, byCollection, daily, members] = await Promise.all([ + collectionFigures(c, org.id), + usageBy(c, 'collection_id', scope), + usageBy(c, 'day', scope), + rows<{ name: string; email: string; role: string }>( + c, + sql`SELECT u.name, u.email, m.role FROM member m + JOIN user u ON u.id = m.user_id WHERE m.organization_id = ${org.id} + ORDER BY u.name`, + ), + ]) + const usage = emptyUsage() + for (const u of daily.values()) for (const m of METRICS) usage[m] += u[m] + return c.json({ + since, + org: { id: org.id, slug: org.slug, name: org.name, personal: !!org.isDefault }, + totals: sums(cols), + usage, + daily: days.map((day) => ({ day, ...(daily.get(day) ?? emptyUsage()) })), + members, + collections: cols.map((col) => ({ ...col, usage: byCollection.get(col.id) ?? emptyUsage() })), + }) + }) + + app.get('/api/admin/stats/corpus', async (c) => { + const [heads, shared, growth, cols] = await Promise.all([ + rows<{ typeCounts: string | null; publicTypeCounts: string | null }>( + c, + sql`SELECT v.type_counts AS typeCounts, v.public_type_counts AS publicTypeCounts + FROM collections c JOIN versions v ON v.id = c.head_version_id + WHERE c.deleted_at IS NULL`, + ), + rows<{ hash: string; type: string; collections: number }>( + c, + sql`SELECT su.schema_hash AS hash, min(su.type_slug) AS type, + count(DISTINCT su.collection_id) AS collections + FROM schema_usage su JOIN collections c ON c.id = su.collection_id + WHERE su.to_seq IS NULL AND c.deleted_at IS NULL + GROUP BY su.schema_hash ORDER BY collections DESC, type LIMIT 20`, + ), + rows<{ month: string; versions: number; added: number; removed: number; updated: number }>( + c, + sql`SELECT strftime('%Y-%m', created_at / 1000, 'unixepoch') AS month, + count(*) AS versions, + coalesce(sum(json_extract(changes, '$.added')), 0) AS added, + coalesce(sum(json_extract(changes, '$.removed')), 0) AS removed, + coalesce(sum(json_extract(changes, '$.updated')), 0) AS updated + FROM versions GROUP BY month ORDER BY month`, + ), + collectionFigures(c), + ]) + const types = new Map() + for (const h of heads) { + const all = JSON.parse(h.typeCounts ?? '{}') as Record + const pub = JSON.parse(h.publicTypeCounts ?? '{}') as Record + for (const [type, count] of Object.entries(all)) { + const t = types.get(type) ?? { records: 0, publicRecords: 0, collections: 0 } + t.records += n(count) + t.publicRecords += n(pub[type]) + t.collections++ + types.set(type, t) + } + } + const orgSlugs = new Map( + (await rows<{ id: string; slug: string }>(c, sql`SELECT id, slug FROM organization`)).map( + (o) => [o.id, o.slug], + ), + ) + const totals = sums(cols) + return c.json({ + totals: { ...totals, collections: cols.length, types: types.size }, + types: [...types] + .map(([type, t]) => ({ type, ...t })) + .sort((a, b) => b.records - a.records) + .slice(0, 50), + sharedSchemas: shared.map((s) => ({ ...s, collections: n(s.collections) })), + largest: [...cols] + .sort((a, b) => b.records - a.records) + .slice(0, 15) + .map((col) => ({ + owner: orgSlugs.get(col.orgId) ?? '?', + slug: col.slug, + public: col.public, + records: col.records, + latestBytes: col.latestBytes, + versions: col.versions, + })), + growth: growth.map((g) => ({ + month: g.month, + versions: n(g.versions), + added: n(g.added), + removed: n(g.removed), + updated: n(g.updated), + })), + }) + }) + + app.get('/api/admin/stats/billing', async (c) => { + const month = c.req.query('month') ?? new Date().toISOString().slice(0, 7) + if (!/^\d{4}-\d{2}$/.test(month)) return jsonError(c, 400, 'month must be YYYY-MM') + const start = Date.parse(`${month}-01T00:00:00Z`) + const end = new Date(start) + end.setUTCMonth(end.getUTCMonth() + 1) + const [{ orgs, cols }, usage, tombstones, reconcile] = await Promise.all([ + orgFigures(c), + // A range, not LIKE: it can use the rollups' day index. + usageBy(c, 'account_id', sql`day >= ${`${month}-01`} AND day < ${isoDay(end.getTime())}`), + rows>( + c, + sql`SELECT t.slug, o.slug AS owner, t.versions, t.total_bytes AS totalBytes, + t.ref_events AS refEvents, t.deleted_at AS deletedAt + FROM collection_tombstones t LEFT JOIN organization o ON o.id = t.organization_id + WHERE t.deleted_at >= ${start} AND t.deleted_at < ${end.getTime()} + ORDER BY t.deleted_at DESC`, + ), + rows<{ running: number; never: number; stale: number }>( + c, + sql`SELECT + coalesce(sum(reconcile_started_at IS NOT NULL + AND (reconciled_at IS NULL OR reconciled_at < reconcile_started_at)), 0) AS running, + coalesce(sum(reconciled_at IS NULL), 0) AS never, + coalesce(sum(reconciled_at < ${Date.now() - 8 * DAY_MS}), 0) AS stale + FROM collections WHERE deleted_at IS NULL`, + ), + ]) + const orgSlug = new Map(orgs.map((o) => [o.id, o.slug])) + return c.json({ + month, + orgs: orgs + .map((o) => ({ + slug: o.slug, + name: o.name, + personal: o.personal, + collections: o.collections, + latestBytes: o.latestBytes, + historyBytes: o.historyBytes, + refEvents: o.refEvents, + refBytes: o.refBytes, + usage: usage.get(o.id) ?? emptyUsage(), + })) + .filter((o) => o.collections > 0 || METRICS.some((m) => o.usage[m] > 0) || o.refEvents > 0), + reconcile: { + collections: cols.length, + running: n(reconcile[0]?.running), + never: n(reconcile[0]?.never), + stale: n(reconcile[0]?.stale), + corrected: cols + .filter((col) => col.corrections > 0) + .map((col) => ({ + owner: orgSlug.get(col.orgId) ?? '?', + slug: col.slug, + corrections: col.corrections, + reconciledAt: col.reconciledAt, + })), + }, + deleted: tombstones.map((t) => ({ + owner: t.owner ?? null, + slug: String(t.slug), + versions: n(t.versions), + totalBytes: n(t.totalBytes), + refEvents: n(t.refEvents), + deletedAt: n(t.deletedAt), + })), + }) + }) + + app.get('/api/admin/stats/operations', async (c) => { + const weekAgo = Date.now() - 7 * DAY_MS + const [sessions, failed, locations, uploads, jobs] = await Promise.all([ + rows<{ status: string; n: number }>( + c, + sql`SELECT status, count(*) AS n FROM push_sessions + WHERE status IN ('open', 'committing') OR created_at >= ${weekAgo} + GROUP BY status`, + ), + rows>( + c, + sql`SELECT s.id, o.slug AS owner, c.slug, s.error, s.created_at AS createdAt + FROM push_sessions s JOIN collections c ON c.id = s.collection_id + JOIN organization o ON o.id = c.organization_id + WHERE s.status = 'failed' AND s.created_at >= ${weekAgo} + ORDER BY s.created_at DESC LIMIT 20`, + ), + rows>( + c, + sql`SELECT l.id, l.name, l.bucket, l.status, l.last_error AS lastError, + l.checked_at AS checkedAt, o.slug AS owner + FROM storage_locations l LEFT JOIN organization o ON o.id = l.organization_id + WHERE l.kind != 'platform' ORDER BY l.status, l.name`, + ), + rows<{ status: string; n: number }>( + c, + sql`SELECT status, count(*) AS n FROM file_uploads + WHERE created_at >= ${weekAgo} GROUP BY status`, + ), + rows<{ type: string; status: string; n: number }>( + c, + sql`SELECT type, status, count(*) AS n FROM jobs + WHERE status != 'done' GROUP BY type, status ORDER BY type`, + ), + ]) + const parseError = (v: unknown) => { + if (typeof v !== 'string') return null + try { + return (JSON.parse(v) as { error?: string }).error ?? v + } catch { + return v + } + } + return c.json({ + sessions: sessions.map((s) => ({ ...s, n: n(s.n) })), + failedPushes: failed.map((f) => ({ + id: String(f.id), + owner: String(f.owner), + slug: String(f.slug), + error: parseError(f.error), + createdAt: n(f.createdAt), + })), + locations: locations.map((l) => ({ + id: String(l.id), + name: String(l.name), + bucket: l.bucket ?? null, + owner: l.owner ?? null, + status: String(l.status), + lastError: l.lastError ?? null, + checkedAt: l.checkedAt == null ? null : n(l.checkedAt), + })), + uploads: Object.fromEntries(uploads.map((u) => [u.status, n(u.n)])), + // The Node runner's table; on Workers jobs go through Queues and don't appear here. + jobs: jobs.map((j) => ({ ...j, n: n(j.n) })), + }) + }) + + return app +} diff --git a/packages/server/src/api/sync.ts b/packages/server/src/api/sync.ts new file mode 100644 index 0000000..ff994c2 --- /dev/null +++ b/packages/server/src/api/sync.ts @@ -0,0 +1,95 @@ +/** + * Tree sync, the server side (edge-redesign-build.md, "Tree sync"), mounted at + * /api/collections. Pull-only: clients and mirror nodes fetch packs and the log. + * + * GET /:owner/:slug/versions/:n/pack?base=&sets=public|all + * a tar of the objects version :n reaches that `base` doesn't + * GET /:owner/:slug/log?after=&limit= + * collection.json (with the signing keys) and log entries after `after` + * + * Access follows the read path: a collection the caller can't read is a 404, + * and `sets=all` needs the private sets. The base must be a version of the same + * collection, so what a pack leaves out says nothing about anything the caller + * couldn't read anyway. + */ +import { + type LogEntry, + packVersion, + readCollectionInfo, + readHead, + readLogEntry, + tarStream, +} from '@underlay/protocol' +import { Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import { findVersion } from '../versions/view.js' +import { jsonError, requireCollection } from './access.js' + +const MAX_LOG_ENTRIES = 1000 + +export function syncRoutes() { + const app = new Hono() + + app.get('/:owner/:slug/versions/:n/pack', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const sets = c.req.query('sets') ?? 'public' + if (sets !== 'public' && sets !== 'all') return jsonError(c, 400, 'sets is public or all') + if (sets === 'all' && !access.sets.includes('private')) { + return jsonError(c, 403, 'The private sets need membership in the owning organization') + } + const { db } = c.var.ports + const id = access.collection.id + const head = access.collection.headVersionId + const version = await findVersion(db, id, c.req.param('n'), head) + if (!version) return jsonError(c, 404, `Version ${c.req.param('n')} not found`) + const baseParam = c.req.query('base') + const base = baseParam ? await findVersion(db, id, baseParam, head) : null + if (baseParam && !base) return jsonError(c, 404, `Base version ${baseParam} not found`) + + const repo = await c.var.ports.stores.forCollection(id) + const objects = packVersion(repo, version.hash, { base: base?.hash ?? null, sets }) + const entries = (async function* () { + for await (const o of objects) { + yield { + name: o.key, + size: o.bytes.byteLength, + body: async function* () { + yield o.bytes + }, + } + } + })() + return new Response(tarStream(entries), { + headers: { + 'content-type': 'application/x-tar', + 'x-underlay-version': version.hash, + 'x-underlay-base': base?.hash ?? '', + 'x-underlay-sets': sets, + }, + }) + }) + + app.get('/:owner/:slug/log', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const after = Math.max(0, Number(c.req.query('after') ?? 0) || 0) + const limit = Math.min( + MAX_LOG_ENTRIES, + Math.max(1, Number(c.req.query('limit')) || MAX_LOG_ENTRIES), + ) + const id = access.collection.id + const repo = await c.var.ports.stores.forCollection(id) + const [collection, head] = await Promise.all([readCollectionInfo(repo, id), readHead(repo, id)]) + const entries: LogEntry[] = [] + for (let seq = after + 1; head && seq <= head.seq && entries.length < limit; seq++) { + const e = await readLogEntry(repo, id, seq) + if (!e) break + entries.push(e) + } + return c.json({ collection, head, entries }) + }) + + return app +} diff --git a/packages/server/src/api/versions.ts b/packages/server/src/api/versions.ts new file mode 100644 index 0000000..5e5c156 --- /dev/null +++ b/packages/server/src/api/versions.ts @@ -0,0 +1,861 @@ +/** + * Version reads (v1 shapes: docs/v1-read-api.md), mounted at /api/collections. + * + * GET /:owner/:slug/versions list (SQLite) + * GET /:owner/:slug/versions/:n detail (+ root metadata, visible schemas) + * GET /:owner/:slug/versions/:n/records page by key or offset; O(height) seeks + * GET /:owner/:slug/versions/:n/records.ndjson stream; ?after_type=&after= resumes + * GET /:owner/:slug/versions/:n/manifest ids and hashes from nodes; ?since= delta + * GET /:owner/:slug/versions/:n/diff ?from= (default: the version before); O(changes) + * GET /:owner/:slug/versions/:n/files from the visible file trees + * + * Privacy is decided once: the view holds the sets the caller may read. + * Records list order: by id within a type; without ?type=, types in slug order + * then id, with a (type, id) cursor (a deliberate change from v1). + */ +import { + compareUtf8, + type DiffEntry, + diffTrees, + fileTree, + gzip, + iterate, + leaves, + OUT_OF_LINE_BYTES, + type RecordEntry, + recordTree, + referenceCounts, + RepoSource, +} from '@underlay/protocol' +import { and, desc, eq, lt } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import { chunks, inJson, JSON_CHUNK } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import { deniedHashes } from '../lib/limits.js' +import type { Db } from '../ports.js' +import { + countAfter, + findVersion, + getRecord, + loadView, + typeRecords, + type VersionRow, + type VersionView, + type VisibleRecord, +} from '../versions/view.js' +import { type CollectionAccess, jsonError, requireCollection } from './access.js' +import { collectionArk } from './ark.js' +import { versionSummary, withPusherNames } from './collections.js' + +const encodeCursor = (t: string, k: string) => + Buffer.from(JSON.stringify({ t, k })).toString('base64url') +function decodeCursor( + s: string | undefined, + type: string | undefined, +): { t: string; k: string } | null { + if (!s) return null + try { + const v = JSON.parse(Buffer.from(s, 'base64url').toString()) as { + t?: unknown + k?: unknown + r?: unknown + } + if (typeof v.t === 'string' && typeof v.k === 'string') return { t: v.t, k: v.k } + // v1 cursors: {r: [recordId, recordHash]} within a type. + if (Array.isArray(v.r) && typeof v.r[0] === 'string' && type) return { t: type, k: v.r[0] } + } catch { + // Not a cursor: a bare record id within ?type= (v1 accepted that). + } + return type ? { t: type, k: s } : null +} + +const clamp = (v: string | undefined, def: number, max: number) => + Math.min(max, Math.max(1, Number(v ?? def) || def)) + +async function viewFor( + c: Context, + access: CollectionAccess, +): Promise { + const n = c.req.param('n') ?? 'latest' + const v = await findVersion( + c.var.ports.db, + access.collection.id, + n, + access.collection.headVersionId, + ) + if (!v) return jsonError(c, 404, n === 'latest' ? 'No versions' : 'Version not found') + const repo = await c.var.ports.stores.forCollection(access.collection.id) + return loadView(repo, v, access.isMember, await deniedHashes(c.var.ports.db)) +} + +/** + * Add the record hash to a canonical record line: `{"id",…,"data":…,"hash":"…"}`, + * and `"private":true` after it for a member's private record. + */ +const withHash = (body: string, hash: string, priv = false) => + `${body.slice(0, -1)},"hash":"${hash}"${priv ? ',"private":true' : ''}}` + +/** A record as reads return it; members' private records say so, as a push line does. */ +function recordJson(e: RecordEntry & { type: string; set?: 'public' | 'private' }) { + const r = JSON.parse(e.body!) as { id: string; type: string; data: unknown } + return { + id: r.id, + type: r.type, + data: r.data, + hash: e.hash, + ...(e.set === 'private' ? { private: true } : {}), + } +} + +/** Records across visible types from a (type, id) position, in (type, id) order. */ +async function* allRecords( + view: VersionView, + from: { t: string; k: string } | null, + offset: number, + bodies: boolean, +) { + let skip = offset + for (const t of view.types) { + if (from && compareUtf8(t.slug, from.t) < 0) continue + if (skip >= t.count) { + skip -= t.count + continue + } + const opts: { after?: string; offset?: number; bodies: boolean } = { bodies } + if (from && t.slug === from.t) opts.after = from.k + else if (skip > 0) opts.offset = skip + skip = 0 + yield* typeRecords(view, t, opts) + } +} + +/** + * Caching for a version's data: a published version never changes, so anything + * addressed by its semver or hash may be cached; `latest` moves. Anonymous + * reads are public for ten minutes, and stale for a minute more while a cache + * revalidates: a collection made private, or a blocked record, stops being + * served within about eleven minutes (accepted, decision D2 of alignment review + * 2; the settings page says so). Members' reads are private. Only 200s are + * cached, so a collection made public is readable as soon as the database says + * so. + */ +function versionCaching(c: Context) { + const n = c.req.param('n') + if (!n || n === 'latest' || c.req.method !== 'GET' || c.res.status !== 200) return + if (c.res.headers.has('cache-control')) return + c.res.headers.set( + 'cache-control', + c.var.principal ? 'private, max-age=3600' : 'public, max-age=600, stale-while-revalidate=60', + ) +} + +export function versionRoutes() { + const app = new Hono() + app.use('/:owner/:slug/versions/:n/*', async (c, next) => { + await next() + versionCaching(c) + }) + app.use('/:owner/:slug/versions/:n', async (c, next) => { + await next() + versionCaching(c) + }) + + app.get('/:owner/:slug/versions', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const limit = clamp(c.req.query('limit'), 50, 100) + const offset = Math.max(0, Number(c.req.query('offset') ?? 0) || 0) + const rows = await c.var.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, access.collection.id)) + .orderBy(desc(schema.versions.seq)) + .limit(limit) + .offset(offset) + const ark = await collectionArk(c.var.ports.db, access.collection.id, access.owner) + return c.json( + await withPusherNames( + c.var.ports.db, + rows.map((v) => versionSummary(v, access.isMember, ark)), + ), + ) + }) + + app.get('/:owner/:slug/versions/:n', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const view = await viewFor(c, access) + if (view instanceof Response) return view + // ?records= (empty: the first type) is a records page's one call: the + // version, a page of that type's records, and only that type's schema. + const pageType = c.req.query('records') + const shown = + pageType === undefined + ? view.types + : view.types.filter((t) => t.slug === (pageType || view.types[0]?.slug)) + const schemas: Record = {} + await Promise.all( + shown.map(async (t) => (schemas[t.slug] = await view.repo.schema(t.schemaHash))), + ) + const t = pageType === undefined ? null : (shown[0] ?? null) + let recordsPage = null + if (pageType !== undefined) { + const limit = clamp(c.req.query('limit'), 100, 2000) + const offset = Math.max(0, Number(c.req.query('offset') ?? 0) || 0) + const records: VisibleRecord[] = [] + if (t) { + for await (const e of typeRecords(view, t, { offset, bodies: true })) { + records.push(e) + if (records.length === limit) break + } + } + recordsPage = { + type: t?.slug ?? null, + records: records.map(recordJson), + total: t?.count ?? 0, + } + } + return c.json({ + ...( + await withPusherNames(c.var.ports.db, [ + versionSummary( + view.version, + view.owner, + await collectionArk(c.var.ports.db, access.collection.id, access.owner), + ), + ]) + )[0], + metadata: view.root.metadata, + typeCounts: Object.fromEntries(view.types.map((t) => [t.slug, t.count])), + schemas, + ...(recordsPage ? { recordsPage } : {}), + }) + }) + + app.get('/:owner/:slug/versions/:n/records', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const view = await viewFor(c, access) + if (view instanceof Response) return view + const type = c.req.query('type') + const limit = clamp(c.req.query('limit'), 100, 2000) + const cursor = decodeCursor(c.req.query('cursor') ?? c.req.query('after'), type) + const offset = cursor ? 0 : Math.max(0, Number(c.req.query('offset') ?? 0) || 0) + let source: AsyncIterable + let total: number + if (type) { + const t = view.types.find((x) => x.slug === type) + if (!t) + return c.json({ + records: [], + pagination: { limit, hasMore: false, nextCursor: null, total: 0 }, + }) + total = t.count + const opts: { after?: string; offset?: number; bodies: boolean } = { bodies: true } + if (cursor) opts.after = cursor.k + else opts.offset = offset + source = typeRecords(view, t, opts) + } else { + total = view.types.reduce((n, t) => n + t.count, 0) + source = allRecords(view, cursor, offset, true) + } + const page: VisibleRecord[] = [] + let hasMore = false + for await (const e of source) { + if (page.length === limit) { + hasMore = true + break + } + page.push(e) + } + const last = page[page.length - 1] + return c.json({ + records: page.map(recordJson), + pagination: { + limit, + hasMore, + nextCursor: hasMore && last ? encodeCursor(last.type, last.key) : null, + total, + }, + }) + }) + + /** One record by type and id, at a version (v1 had this only through ARK). */ + app.get('/:owner/:slug/versions/:n/records/:type/:id', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const view = await viewFor(c, access) + if (view instanceof Response) return view + const t = view.types.find((x) => x.slug === c.req.param('type')) + const rec = t ? await getRecord(view, t, c.req.param('id')) : null + if (!rec) return jsonError(c, 404, 'Record not found') + return c.json({ ...recordJson(rec), semver: view.version.semver }) + }) + + /** + * A record's history in this collection: every version where it was added, + * changed or removed, oldest first, in the sets the caller may read. One + * lookup per version, O(versions × tree height); the newest MAX_HISTORY versions. + */ + app.get('/:owner/:slug/records/:type/:id/history', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const { db } = c.var.ports + const versions = ( + await db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, access.collection.id)) + .orderBy(desc(schema.versions.seq)) + .limit(MAX_HISTORY) + ).reverse() + const repo = await c.var.ports.stores.forCollection(access.collection.id) + const withheld = await deniedHashes(db) + const source = new RepoSource(recordTree, repo) + const type = c.req.param('type') + const id = c.req.param('id') + const changes: { + seq: number + semver: string + createdAt: Date + change: 'added' | 'updated' | 'removed' + hash: string | null + }[] = [] + let last: string | null = null + // Consecutive versions share most of the path to the record: a node seen + // before gives the same answer, so a lookup stops at the first shared node. + const memo = new Map() + for (const v of versions) { + const view = await loadView(repo, v, access.isMember, withheld) + const t = view.types.find((x) => x.slug === type) + let hash: string | null = null + for (const tree of [t?.public, t?.private]) { + if (!tree?.root) continue + const hit = await lookupShared(source, tree.root, id, memo) + if (hit) hash = hit.hash + } + if (hash === last) continue + changes.push({ + seq: v.seq, + semver: v.semver, + createdAt: v.createdAt, + change: hash === null ? 'removed' : last === null ? 'added' : 'updated', + hash, + }) + last = hash + } + if (changes.length === 0) return jsonError(c, 404, 'Record not found') + return c.json({ type, id, changes, truncated: versions.length === MAX_HISTORY }) + }) + + /** + * A type's public records as one gzip file: the stored leaf bodies concatenated + * (gzip allows several members), so a public read is copied, not re-encoded. + * Lines are canonical records, `{"id":…,"type":…,"data":…}`, without the + * `hash` records.ndjson adds (it's the SHA-256 of the line). Only the public + * set: a member's private records interleave by id, so they need records.ndjson. + * A leaf holding out-of-line or blocked records is re-encoded. Some gzip readers + * (browsers' DecompressionStream) stop after the first member. + */ + app.get('/:owner/:slug/versions/:n/records.ndjson.gz', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const view = await viewFor(c, access) + if (view instanceof Response) return view + const want = c.req.query('type') + const types = view.types.filter((t) => t.public?.root && (!want || t.slug === want)) + if (want && types.length === 0) return jsonError(c, 404, `No public records of type ${want}`) + const source = new RepoSource(recordTree, view.repo) + const body = new ReadableStream({ + async start(ctl) { + try { + for (const t of types) { + for await (const leaf of leaves(source, t.public!.root)) { + const node = await source.node(leaf.hash) + const entries = (node.kind === 'leaf' ? node.entries : []) as RecordEntry[] + const plain = entries.every( + (e) => e.size <= OUT_OF_LINE_BYTES && !view.withheld.has(e.hash), + ) + // Billed as the NDJSON it decompresses to: a line and a newline per record. + c.var.meter.logicalBytes = + (c.var.meter.logicalBytes ?? 0) + + entries.reduce((n, e) => (view.withheld.has(e.hash) ? n : n + e.size + 1), 0) + if (plain) { + ctl.enqueue(await view.repo.rawBody(leaf.hash)) + continue + } + const lines = (await view.repo.bodyLines({ hash: leaf.hash, entries })).filter( + (_, i) => !view.withheld.has(entries[i]!.hash), + ) + if (lines.length) ctl.enqueue(await gzip(lines.join('\n') + '\n')) + } + } + ctl.close() + } catch (err) { + ctl.error(err) + } + }, + }) + return new Response(body, { + headers: { + 'content-type': 'application/gzip', + 'content-disposition': `attachment; filename="${access.owner.slug}-${access.collection.slug}-${view.version.semver}${want ? `-${want}` : ''}.ndjson.gz"`, + }, + }) + }) + + app.get('/:owner/:slug/versions/:n/records.ndjson', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const view = await viewFor(c, access) + if (view instanceof Response) return view + const type = c.req.query('type') + // A resume names the last line read: ?after_type=T&after=id continues after + // (T, id) through the later types; ?type=T&after=id stays within T. + const after = c.req.query('after') + const afterType = c.req.query('after_type') + if (afterType !== undefined && !after) + return jsonError(c, 400, 'after_type needs after: the id of the last record read') + if (after && !afterType && !type) + return jsonError( + c, + 400, + 'after needs after_type: pass ?after_type=&after= to resume after that record', + ) + const from = after ? { t: (afterType || type)!, k: after } : null + const types = (type ? view.types.filter((t) => t.slug === type) : view.types).filter( + (t) => !from || compareUtf8(t.slug, from.t) >= 0, + ) + let count = 0 + for (const t of types) count += from?.t === t.slug ? await countAfter(view, t, from.k) : t.count + const enc = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + try { + for (const t of types) { + const opts = from?.t === t.slug ? { after: from.k, bodies: true } : { bodies: true } + let batch: string[] = [] + for await (const e of typeRecords(view, t, opts)) { + batch.push(withHash(e.body!, e.hash, e.set === 'private')) + if (batch.length >= 512) { + controller.enqueue(enc.encode(batch.join('\n') + '\n')) + batch = [] + } + } + if (batch.length) controller.enqueue(enc.encode(batch.join('\n') + '\n')) + } + controller.close() + } catch (err) { + controller.error(err) + } + }, + }) + return new Response(stream, { + headers: { 'content-type': 'application/x-ndjson', 'x-underlay-record-count': String(count) }, + }) + }) + + app.get('/:owner/:slug/versions/:n/manifest', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const view = await viewFor(c, access) + if (view instanceof Response) return view + // Pages and the file list are bounded for a 128 MB isolate: tens of MB of JSON otherwise. + const limit = clamp(c.req.query('limit'), 10_000, MANIFEST_MAX) + const cursor = decodeCursor(c.req.query('cursor'), undefined) + const schemas = Object.fromEntries(view.types.map((t) => [t.slug, t.schemaHash])) + const since = c.req.query('since') + const fileList = async () => { + if (cursor) return {} + const files = await visibleFiles(view, MANIFEST_MAX + 1) + return files.length > MANIFEST_MAX + ? { files: files.slice(0, MANIFEST_MAX), filesTruncated: true } + : { files } + } + + if (since) { + const from = await findVersion(c.var.ports.db, access.collection.id, since, null) + if (!from) return jsonError(c, 404, `Version ${since} not found`) + const fromView = await loadView(view.repo, from, view.owner, view.withheld) + const delta = { added: [] as object[], updated: [] as object[], removed: [] as object[] } + let n = 0 + let next: string | null = null + let last: { type: string; key: string } | null = null + // A member sees which set each record is in (a removal: the set it left), + // and a move between sets as an update even when the hash is kept. A + // public reader sees the public set only, where a move adds or removes. + const inSet = (set: 'public' | 'private') => (set === 'private' ? { private: true } : {}) + for await (const d of diffAll(fromView, view, cursor, { moves: true })) { + // Resuming skips up to and including the cursor: it names the last entry returned. + if (n === limit) { + next = encodeCursor(last!.type, last!.key) + break + } + n++ + last = d + if (!d.before) + delta.added.push({ id: d.key, type: d.type, hash: d.after!.hash, ...inSet(d.afterSet) }) + else if (!d.after) + delta.removed.push({ + id: d.key, + type: d.type, + hash: d.before.hash, + ...inSet(d.beforeSet), + }) + else + delta.updated.push({ + id: d.key, + type: d.type, + hash: d.after.hash, + previousHash: d.before.hash, + ...inSet(d.afterSet), + ...(d.beforeSet !== d.afterSet ? { previousPrivate: d.beforeSet === 'private' } : {}), + }) + } + return c.json({ + semver: view.version.semver, + hash: view.version.hash, + since: from.semver, + schemas, + delta, + // The file list rides on the first page only, as for a full manifest. + files: [], + ...(await fileList()), + pagination: { limit, hasMore: next !== null, nextCursor: next }, + }) + } + + const records: object[] = [] + let next: string | null = null + // Nodes only: ids and hashes, no bodies. + // Resuming starts after the cursor, so it names the last entry returned. + for await (const e of allRecords(view, cursor, 0, false)) { + if (records.length === limit) { + const last = records[records.length - 1] as { id: string; type: string } + next = encodeCursor(last.type, last.id) + break + } + records.push({ + id: e.key, + type: e.type, + hash: e.hash, + ...(e.set === 'private' ? { private: true } : {}), + }) + } + return c.json({ + semver: view.version.semver, + hash: view.version.hash, + schemas, + records, + files: [], + ...(await fileList()), + pagination: { limit, hasMore: next !== null, nextCursor: next }, + }) + }) + + app.get('/:owner/:slug/versions/:n/diff', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const view = await viewFor(c, access) + if (view instanceof Response) return view + const limit = clamp(c.req.query('limit'), 500, 5000) + const cursor = decodeCursor(c.req.query('cursor'), undefined) + // Without ?from=, the version before this one; the first version diffs against nothing. + const fromParam = c.req.query('from') + const from = fromParam + ? await findVersion(c.var.ports.db, access.collection.id, fromParam, null) + : await previousVersion(c.var.ports.db, view.version) + if (fromParam && !from) return jsonError(c, 404, `Version ${fromParam} not found`) + const fromView = from ? await loadView(view.repo, from, view.owner, view.withheld) : null + const added: object[] = [] + const updated: object[] = [] + const removed: { id: string; type: string }[] = [] + let n = 0 + let next: string | null = null + let last: { type: string; key: string } | null = null + const body = async (side: VersionView, d: { type: string; key: string }) => { + const t = side.types.find((x) => x.slug === d.type)! + const r = await getRecord(side, t, d.key) + return { id: d.key, type: d.type, data: (JSON.parse(r!.body!) as { data: unknown }).data } + } + const page: TypedDiff[] = [] + for await (const d of diffAll(fromView, view, cursor)) { + if (n === limit) { + next = encodeCursor(last!.type, last!.key) + break + } + n++ + last = d + page.push(d) + } + // A page's bodies are read BODY_READS at a time, not one after another. + const bodies = await mapConcurrent(page, BODY_READS, (d) => + d.after ? body(view, d) : Promise.resolve(null), + ) + page.forEach((d, i) => { + if (!d.before) added.push(bodies[i]!) + else if (!d.after) removed.push({ id: d.key, type: d.type }) + else updated.push(bodies[i]!) + }) + const schemaChanged = + !fromView || + JSON.stringify(fromView.types.map((t) => [t.slug, t.schemaHash])) !== + JSON.stringify(view.types.map((t) => [t.slug, t.schemaHash])) + // File counts re-diff both file trees: on the first page only, not every page. + const fileDelta = cursor ? { added: 0, removed: 0 } : await fileCounts(fromView, view) + return c.json({ + from: from?.semver ?? null, + to: view.version.semver, + added, + updated, + removed, + pagination: { limit, hasMore: next !== null, nextCursor: next }, + meta: { + schemaChanged, + metadataChanged: + JSON.stringify(fromView?.root.metadata ?? null) !== JSON.stringify(view.root.metadata), + filesAdded: fileDelta.added, + filesRemoved: fileDelta.removed, + }, + }) + }) + + app.get('/:owner/:slug/versions/:n/files', async (c) => { + const access = await requireCollection(c, 'read') + if (access instanceof Response) return access + const view = await viewFor(c, access) + if (view instanceof Response) return view + const hashes = await visibleFiles(view, 10_000) + const rows = [] + for (const part of chunks(hashes, JSON_CHUNK)) { + rows.push( + ...(await c.var.ports.db + .select() + .from(schema.files) + .where(inJson(schema.files.hash, part))), + ) + } + const byHash = new Map(rows.map((r) => [r.hash, r])) + // Which records reference a file isn't indexed in v2 (it would take a scan of + // every body); how many do is, in each set's count tree. A caller who reads + // only the public set sees only public references, as in v1. + // Only the listed hashes' range of each count tree, not the whole tree. + const range = hashes.length ? { from: hashes[0]!, through: hashes.at(-1)! } : undefined + const counts = range + ? await referenceCounts(view.repo, view.version.publicRefsRoot, range) + : new Map() + if (view.private && range) { + for (const [h, n] of await referenceCounts(view.repo, view.version.privateRefsRoot, range)) + counts.set(h, (counts.get(h) ?? 0) + n) + } + return c.json( + hashes.map((h) => ({ + hash: h, + size: byHash.get(h)?.size ?? null, + mimeType: byHash.get(h)?.mimeType ?? null, + createdAt: byHash.get(h)?.createdAt ?? null, + referenceCount: counts.get(h) ?? 0, + references: [], + })), + ) + }) + + return app +} + +/** The version just before `v` in its collection, or null for the first. */ +async function previousVersion(db: Db, v: VersionRow): Promise { + const [prev] = await db + .select() + .from(schema.versions) + .where(and(eq(schema.versions.collectionId, v.collectionId), lt(schema.versions.seq, v.seq))) + .orderBy(desc(schema.versions.seq)) + .limit(1) + return prev ?? null +} + +/** Most manifest records per page, and files in its list (underlay.org's caps; spec 11.3). */ +const MANIFEST_MAX = 25_000 + +/** + * A key's entry in a record tree, remembering the answer for every node on the + * path: a later lookup that reaches a node already seen (an unchanged subtree) + * stops there. + */ +async function lookupShared( + source: RepoSource, + root: string, + key: string, + memo: Map, +): Promise { + const path: string[] = [] + let at: string | null = root + let found: RecordEntry | null = null + while (at !== null) { + const known = memo.get(at) + if (known !== undefined) { + found = known + break + } + path.push(at) + const node = await source.node(at) + if (node.kind === 'leaf') { + found = (node.entries as RecordEntry[]).find((e) => e.key === key) ?? null + break + } + at = node.children.find((ch) => compareUtf8(key, ch.lastKey) <= 0)?.hash ?? null + } + for (const h of path) memo.set(h, found) + return found +} + +/** Versions a record history looks back over. */ +const MAX_HISTORY = 500 + +/** Concurrent body reads per diff page. */ +const BODY_READS = 16 + +/** `f` over `items`, at most `limit` at a time, results in order. */ +async function mapConcurrent( + items: T[], + limit: number, + f: (item: T) => Promise, +): Promise { + const out = new Array(items.length) + let next = 0 + const worker = async () => { + while (next < items.length) { + const i = next++ + out[i] = await f(items[i]!) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + return out +} + +/** File hashes in the sets this view may read (deduplicated, sorted). */ +async function visibleFiles(view: VersionView, max: number): Promise { + const source = new RepoSource(fileTree, view.repo) + const roots = [view.public.files.root, view.private?.files.root ?? null] + const out = new Set() + for (const r of roots) { + for await (const f of iterate(source, r)) { + out.add(f.key) + if (out.size >= max) break + } + } + return [...out].sort() +} + +/** Files added and removed from `a` to `b`; with no `a` (a first version), all of b's are added. */ +async function fileCounts(a: VersionView | null, b: VersionView) { + const source = new RepoSource(fileTree, b.repo) + let added = 0 + let removed = 0 + for (const [x, y] of [ + [a?.public.files.root ?? null, b.public.files.root], + [a?.private?.files.root ?? null, b.private?.files.root ?? null], + ] as const) { + for await (const d of diffTrees(source, x, y)) { + if (!d.before) added++ + else if (!d.after) removed++ + } + } + return { added, removed } +} + +type TypedDiff = { + type: string + key: string + before: RecordEntry | null + after: RecordEntry | null + /** The set each side is in (meaningful where that side is not null). */ + beforeSet: 'public' | 'private' + afterSet: 'public' | 'private' +} + +/** + * Differences between two views, by (type, id), resuming after a cursor. A + * record moving between sets with the same hash isn't a change to a reader who + * sees both, unless `moves` asks for those; to a public reader it appears or + * disappears. + */ +async function* diffAll( + from: VersionView | null, + to: VersionView, + cursor: { t: string; k: string } | null, + { moves = false }: { moves?: boolean } = {}, +): AsyncGenerator { + const source = new RepoSource(recordTree, to.repo) + // A change to a blocked record (the denylist) isn't served. + const shown = (d: TypedDiff) => !(d.after && to.withheld.has(d.after.hash)) + const slugs = new Set([...(from?.types.map((t) => t.slug) ?? []), ...to.types.map((t) => t.slug)]) + for (const slug of [...slugs].sort(compareUtf8)) { + if (cursor && compareUtf8(slug, cursor.t) < 0) continue + const a = from?.types.find((t) => t.slug === slug) + const b = to.types.find((t) => t.slug === slug) + // Each set's diff resumes past the cursor, and the two are joined in key + // order as they stream: memory is O(1) per type and a page costs that page. + const after = cursor && slug === cursor.t ? { after: cursor.k } : {} + const side = (set: 'public' | 'private') => + set === 'private' && !to.owner + ? null + : diffTrees(source, a?.[set]?.root ?? null, b?.[set]?.root ?? null, after)[ + Symbol.asyncIterator + ]() + const pub = side('public') + const priv = side('private') + let x = pub ? await pub.next() : null + let y = priv ? await priv.next() : null + const live = (r: IteratorResult> | null) => + r && !r.done ? r.value : null + for (;;) { + const p = live(x) + const q = live(y) + if (!p && !q) break + const order = p && q ? compareUtf8(p.key, q.key) : p ? -1 : 1 + if (order < 0) { + const d: TypedDiff = { + type: slug, + key: p!.key, + before: p!.before, + after: p!.after, + beforeSet: 'public', + afterSet: 'public', + } + if (shown(d)) yield d + x = await pub!.next() + } else if (order > 0) { + const d: TypedDiff = { + type: slug, + key: q!.key, + before: q!.before, + after: q!.after, + beforeSet: 'private', + afterSet: 'private', + } + if (shown(d)) yield d + y = await priv!.next() + } else { + // The same id changed in both sets: a move. Combine the halves; one + // that keeps its hash isn't a change to a reader of both sets. + const before = p!.before ?? q!.before + const after_ = p!.after ?? q!.after + const d: TypedDiff = { + type: slug, + key: p!.key, + before, + after: after_, + beforeSet: p!.before ? 'public' : 'private', + afterSet: p!.after ? 'public' : 'private', + } + const kept = before && after_ && before.hash === after_.hash + if ((moves || !kept) && shown(d)) yield d + x = await pub!.next() + y = await priv!.next() + } + } + } +} diff --git a/packages/server/src/api/webhooks.ts b/packages/server/src/api/webhooks.ts new file mode 100644 index 0000000..c610ac5 --- /dev/null +++ b/packages/server/src/api/webhooks.ts @@ -0,0 +1,228 @@ +/** + * Webhook management (v1 shapes), mounted at /api/collections. Org owners and + * admins only. + * + * GET /:owner/:slug/webhooks + * POST /:owner/:slug/webhooks {url, bumpFilter?, enabled?} → secret, once + * PATCH /:owner/:slug/webhooks/:id + * DELETE /:owner/:slug/webhooks/:id + * POST /:owner/:slug/webhooks/:id/test a signed ping, logged as a delivery + * GET /:owner/:slug/webhooks/:id/deliveries?limit + * POST /:owner/:slug/webhooks/:id/deliveries/:deliveryId/retry + */ +import { and, desc, eq } from 'drizzle-orm' +import { type Context, Hono } from 'hono' + +import type { AppEnv } from '../app.js' +import * as schema from '../db/schema.js' +import { generateWebhookSecret, validateWebhookUrl } from '../webhooks/webhooks.js' +import { type CollectionAccess, jsonError, requireCollection } from './access.js' + +const BUMPS = ['major', 'minor', 'patch'] as const +type Bump = (typeof BUMPS)[number] +const bumpList = (v: unknown): Bump[] | null => + Array.isArray(v) && v.length > 0 && v.every((b) => BUMPS.includes(b)) ? (v as Bump[]) : null + +const publicFields = { + id: schema.collectionWebhooks.id, + url: schema.collectionWebhooks.url, + bumpFilter: schema.collectionWebhooks.bumpFilter, + enabled: schema.collectionWebhooks.enabled, + createdAt: schema.collectionWebhooks.createdAt, + lastDeliveryAt: schema.collectionWebhooks.lastDeliveryAt, +} + +async function requireAdmin(c: Context): Promise { + const access = await requireCollection(c, 'write') + if (access instanceof Response) return access + if (access.role !== 'owner' && access.role !== 'admin') return jsonError(c, 403, 'Forbidden') + return access +} + +const allowInsecure = (c: Context) => + c.var.config.deployment === 'dev' || c.var.config.deployment === 'test' + +export function webhookRoutes() { + const app = new Hono() + + app.get('/:owner/:slug/webhooks', async (c) => { + const access = await requireAdmin(c) + if (access instanceof Response) return access + const webhooks = await c.var.ports.db + .select(publicFields) + .from(schema.collectionWebhooks) + .where(eq(schema.collectionWebhooks.collectionId, access.collection.id)) + .orderBy(desc(schema.collectionWebhooks.createdAt)) + return c.json({ webhooks }) + }) + + app.post('/:owner/:slug/webhooks', async (c) => { + const access = await requireAdmin(c) + if (access instanceof Response) return access + const body = (await c.req.json().catch(() => null)) as { + url?: unknown + bumpFilter?: unknown + enabled?: unknown + } | null + if (typeof body?.url !== 'string') return jsonError(c, 400, '"url" is required') + const check = validateWebhookUrl(body.url, allowInsecure(c)) + if (!check.ok) return jsonError(c, 422, check.reason) + const bumpFilter = body.bumpFilter === undefined ? [...BUMPS] : bumpList(body.bumpFilter) + if (!bumpFilter) + return jsonError(c, 400, '"bumpFilter" must be a non-empty list of major, minor, patch') + const secret = generateWebhookSecret() + const [created] = await c.var.ports.db + .insert(schema.collectionWebhooks) + .values({ + collectionId: access.collection.id, + url: check.url, + bumpFilter, + secret, + enabled: body.enabled !== false, + createdBy: c.var.principal?.userId ?? null, + }) + .returning(publicFields) + return c.json({ ...created, secret }, 201) + }) + + app.patch('/:owner/:slug/webhooks/:id', async (c) => { + const access = await requireAdmin(c) + if (access instanceof Response) return access + const body = (await c.req.json().catch(() => ({}))) as { + url?: unknown + bumpFilter?: unknown + enabled?: unknown + } + const set: Partial = {} + if (body.url !== undefined) { + if (typeof body.url !== 'string') return jsonError(c, 400, '"url" must be a string') + const check = validateWebhookUrl(body.url, allowInsecure(c)) + if (!check.ok) return jsonError(c, 422, check.reason) + set.url = check.url + } + if (body.bumpFilter !== undefined) { + const b = bumpList(body.bumpFilter) + if (!b) + return jsonError(c, 400, '"bumpFilter" must be a non-empty list of major, minor, patch') + set.bumpFilter = b + } + if (body.enabled !== undefined) set.enabled = body.enabled === true + if (Object.keys(set).length === 0) return c.json({ ok: true }) + const [updated] = await c.var.ports.db + .update(schema.collectionWebhooks) + .set({ ...set, updatedAt: new Date() }) + .where( + and( + eq(schema.collectionWebhooks.id, c.req.param('id')), + eq(schema.collectionWebhooks.collectionId, access.collection.id), + ), + ) + .returning(publicFields) + return updated ? c.json(updated) : jsonError(c, 404, 'Not found') + }) + + app.delete('/:owner/:slug/webhooks/:id', async (c) => { + const access = await requireAdmin(c) + if (access instanceof Response) return access + const deleted = await c.var.ports.db + .delete(schema.collectionWebhooks) + .where( + and( + eq(schema.collectionWebhooks.id, c.req.param('id')), + eq(schema.collectionWebhooks.collectionId, access.collection.id), + ), + ) + .returning({ id: schema.collectionWebhooks.id }) + return deleted.length ? c.json({ ok: true }) : jsonError(c, 404, 'Not found') + }) + + app.post('/:owner/:slug/webhooks/:id/test', async (c) => { + const access = await requireAdmin(c) + if (access instanceof Response) return access + const { db } = c.var.ports + const [hook] = await db + .select({ id: schema.collectionWebhooks.id }) + .from(schema.collectionWebhooks) + .where( + and( + eq(schema.collectionWebhooks.id, c.req.param('id')), + eq(schema.collectionWebhooks.collectionId, access.collection.id), + ), + ) + .limit(1) + if (!hook) return jsonError(c, 404, 'Not found') + const [delivery] = await db + .insert(schema.webhookDeliveries) + .values({ + webhookId: hook.id, + collectionId: access.collection.id, + bumpType: 'patch', + event: 'ping', + payload: { + event: 'ping', + collection: { owner: access.owner.slug, slug: access.collection.slug }, + version: null, + bumpType: 'patch', + test: true, + }, + }) + .returning({ id: schema.webhookDeliveries.id }) + await c.var.ports.jobs.enqueue({ type: 'webhooks.deliver', deliveryId: delivery!.id }) + return c.json({ ok: true, deliveryId: delivery!.id }) + }) + + app.get('/:owner/:slug/webhooks/:id/deliveries', async (c) => { + const access = await requireAdmin(c) + if (access instanceof Response) return access + const limit = Math.min(200, Math.max(1, Number(c.req.query('limit') ?? 50) || 50)) + const deliveries = await c.var.ports.db + .select({ + id: schema.webhookDeliveries.id, + event: schema.webhookDeliveries.event, + semver: schema.webhookDeliveries.semver, + bumpType: schema.webhookDeliveries.bumpType, + status: schema.webhookDeliveries.status, + attempts: schema.webhookDeliveries.attempts, + responseCode: schema.webhookDeliveries.responseCode, + error: schema.webhookDeliveries.error, + durationMs: schema.webhookDeliveries.durationMs, + createdAt: schema.webhookDeliveries.createdAt, + deliveredAt: schema.webhookDeliveries.deliveredAt, + }) + .from(schema.webhookDeliveries) + .where( + and( + eq(schema.webhookDeliveries.webhookId, c.req.param('id')), + eq(schema.webhookDeliveries.collectionId, access.collection.id), + ), + ) + .orderBy(desc(schema.webhookDeliveries.createdAt)) + .limit(limit) + return c.json({ deliveries }) + }) + + app.post('/:owner/:slug/webhooks/:id/deliveries/:deliveryId/retry', async (c) => { + const access = await requireAdmin(c) + if (access instanceof Response) return access + const [d] = await c.var.ports.db + .select({ id: schema.webhookDeliveries.id }) + .from(schema.webhookDeliveries) + .where( + and( + eq(schema.webhookDeliveries.id, c.req.param('deliveryId')), + eq(schema.webhookDeliveries.webhookId, c.req.param('id')), + eq(schema.webhookDeliveries.collectionId, access.collection.id), + ), + ) + .limit(1) + if (!d) return jsonError(c, 404, 'Not found') + await c.var.ports.db + .update(schema.webhookDeliveries) + .set({ status: 'pending', attempts: 0 }) + .where(eq(schema.webhookDeliveries.id, d.id)) + await c.var.ports.jobs.enqueue({ type: 'webhooks.deliver', deliveryId: d.id }) + return c.json({ ok: true, status: 'pending' }) + }) + + return app +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts new file mode 100644 index 0000000..bc129bb --- /dev/null +++ b/packages/server/src/app.ts @@ -0,0 +1,328 @@ +/** + * The Hono app. Runtime-agnostic: each entry (Node, Worker) supplies a function + * that builds the ports, config and authenticator for a request, and everything + * below reads them from the context. + */ +import { type Context, type ExecutionContext, Hono } from 'hono' + +import type { Principal } from './api/access.js' +import { accountRoutes } from './api/accounts.js' +import { adminRoutes } from './api/admin.js' +import { agentRoutes } from './api/agent.js' +import { arkRoutes } from './api/ark.js' +import { avatarRoutes } from './api/avatars.js' +import { cleanupRoutes } from './api/cleanup.js' +import { collectionRoutes } from './api/collections.js' +import { exportRoutes } from './api/export.js' +import { fileRoutes } from './api/files.js' +import { locationRoutes } from './api/locations.js' +import { manageRoutes } from './api/manage.js' +import { pushRoutes } from './api/push.js' +import { recordRoutes } from './api/records.js' +import { schemaRoutes } from './api/schemas.js' +import { statsRoutes } from './api/stats.js' +import { syncRoutes } from './api/sync.js' +import { versionRoutes } from './api/versions.js' +import { webhookRoutes } from './api/webhooks.js' +import type { Kf } from './auth/kf.js' +import { type Meter, meter, newMeter } from './billing/usage.js' +import { clientIp, requestCost } from './lib/limits.js' +import type { Ports } from './ports.js' + +export interface AppConfig { + /** Public origin, e.g. https://staging.underlay.org */ + appUrl: string + /** "staging", "next", "production" or "dev": shown in /api/health. */ + deployment: string + /** KF Auth URLs the UI links to (account settings, sign-out). */ + kfAuthUrl?: string | undefined + kfAccountUrl?: string | undefined +} + +export type Authenticate = (req: Request, ports: Ports) => Promise + +/** + * What one page view's in-process API calls share: the caller, authenticated + * once for the page, and collection access, resolved once per collection. + */ +export interface PageContext { + principal: Principal | null + access: Map> +} + +export type AppEnv = { + Bindings: Record + Variables: { + ports: Ports + config: AppConfig + principal: Principal | null + /** KF Auth profile and orgs; null where the deployment has no KF Auth. */ + kf: Kf | null + /** This request's usage (billing/usage.ts). */ + meter: Meter + /** Set on the page renderer's in-process API calls: what the page's calls share. */ + page: PageContext | null + } +} + +/** + * Builds a request's ports, config and authenticator. Runs per request: on + * Workers, bindings and the execution context belong to the invocation. + */ +export type Setup = (c: Context) => { + ports: Ports + config: AppConfig + authenticate: Authenticate + kf?: Kf + /** better-auth's own routes (/api/auth/*): sign-in, callbacks, sessions, keys, orgs. */ + authHandler?: (req: Request) => Promise + /** + * Server-side rendering of UI pages (packages/web). `api` calls this app + * in-process: a Worker can't fetch its own zone (build doc finding 9), and on + * Node it saves a loopback round trip. + */ + renderPage?: RenderPage +} + +export type RenderPage = ( + req: Request, + api: (req: Request) => Promise, +) => Promise + +export function createApp(setup: Setup) { + const app = new Hono() + /** Requests the page renderer makes to this app in-process, with their page's context. */ + const inProcess = new WeakMap() + + // better-auth's org update and delete skip Underlay's rules (slugs, logos, the + // check that an org holds no collections). The account routes do both. + app.post('/api/auth/organization/update', (c) => + c.json({ error: 'Update an organization with PATCH /api/accounts/:slug' }, 404), + ) + app.post('/api/auth/organization/delete', (c) => + c.json({ error: 'Delete an organization with DELETE /api/accounts/:slug' }, 404), + ) + + // The auth routes come before the principal is known: their budget is the IP's. + app.use('/api/auth/*', async (c, next) => { + const limiter = setup(c).ports.rateLimit + if (!limiter || inProcess.has(c.req.raw)) return next() + if (await limiter.check('page', `auth:${clientIp(c.req.raw.headers)}`)) return next() + c.header('Retry-After', '60') + return c.json({ error: 'Rate limit exceeded', statusCode: 429 }, 429) + }) + + app.on(['GET', 'POST'], '/api/auth/*', (c) => { + const { authHandler } = setup(c) + return authHandler + ? authHandler(c.req.raw) + : c.json({ error: 'Auth is not configured', statusCode: 404 }, 404) + }) + + // Pages that moved: the protocol into the docs, the steward hub to /admin. + app.get('/protocol', (c) => c.redirect('/docs/protocol', 301)) + app.get('/superadmin', (c) => c.redirect('/admin', 301)) + + // Old blog URLs: the posts moved to the KF site under the same slugs + // (web's lib/kf-updates.ts links there too). `blog` is a reserved org slug. + const KF_SITE = 'https://www.knowledgefutures.org' + app.get('/blog', (c) => c.redirect(`${KF_SITE}/?tag=underlay`, 301)) + app.get('/blog/:slug', (c) => { + const slug = c.req.param('slug') + return /^[a-z0-9-]+$/i.test(slug) + ? c.redirect(`${KF_SITE}/updates/${slug.toLowerCase()}`, 301) + : c.redirect(`${KF_SITE}/?tag=underlay`, 301) + }) + + // The UI's sign-in links point here; the page itself only shows errors (as v1's + // server.ts). Without this, /login renders a page that redirects to /login. + app.get('/login', async (c, next) => { + const { authHandler, config } = setup(c) + if (!authHandler || c.req.query('error') !== undefined) return next() + const origin = new URL(config.appUrl).origin + // Only a path on this site: an open redirect would hand sign-ins to anyone. + const returnTo = c.req.query('return_to') ?? '' + const callbackURL = /^\/(?![/\\])/.test(returnTo) ? returnTo : '/dashboard' + const headers = new Headers({ + 'content-type': 'application/json', + cookie: c.req.header('cookie') ?? '', + origin, + }) + const res = await authHandler( + new Request(`${origin}/api/auth/sign-in/oauth2`, { + method: 'POST', + headers, + body: JSON.stringify({ + providerId: 'kf-auth', + callbackURL, + errorCallbackURL: '/login', + }), + }), + ) + const body = (await res.json().catch(() => null)) as { url?: string } | null + if (!body?.url) return next() + const redirect = new Response(null, { status: 302, headers: { location: body.url } }) + for (const cookie of res.headers.getSetCookie()) redirect.headers.append('set-cookie', cookie) + return redirect + }) + + app.use('*', async (c, next) => { + const { ports, config, authenticate, kf } = setup(c) + c.set('ports', ports) + c.set('config', config) + c.set('kf', kf ?? null) + const page = inProcess.get(c.req.raw) ?? null + c.set('page', page) + try { + c.set('principal', page ? page.principal : await authenticate(c.req.raw, ports)) + } catch (err) { + if ((err as Error).name !== 'InvalidKeyError') throw err + return c.json({ error: (err as Error).message, statusCode: 401 }, 401) + } + c.set('meter', newMeter()) + await next() + }) + + // Usage (billing/usage.ts): a collection API call and its response bytes, billed + // to the collection's owner, plus whatever the route metered. A page's own + // in-process API calls are part of the page view, which isn't metered. + app.use('/api/*', async (c, next) => { + await next() + const sink = c.var.ports.usage + if (inProcess.has(c.req.raw) || !sink) return + const m = c.var.meter + if (!m.collection) { + if (m.events.length) sink.record(m.events) + return + } + meter(m, 'api_calls', 1) + // The call and what the route metered count now, whatever becomes of the body. + sink.record(m.events.slice()) + const body = c.res.body + if (!body) return + // Response bytes are counted as they go out (most responses don't know their + // length up front), and recorded when the body ends or the client goes away. + const sent = m.events.length + let bytes = 0 + let done = false + const finish = () => { + if (done) return + done = true + meter(m, 'response_bytes', m.logicalBytes ?? bytes) + sink.record(m.events.slice(sent)) + } + const counted = body.pipeThrough( + new TransformStream({ + transform(chunk, ctl) { + bytes += chunk.byteLength + ctl.enqueue(chunk) + }, + flush: finish, + cancel: finish, + } as Transformer), + ) + c.res = new Response(counted, c.res) + }) + + // A response about a collection names its id (spec 11.3.1), whichever URL form + // reached it: requireCollection records the collection once the caller may read it. + app.use('/api/collections/*', async (c, next) => { + await next() + const id = c.var.meter?.collection?.id + if (!id) return + try { + c.res.headers.set('x-underlay-collection', id) + } catch { + // A response with immutable headers (one fetched from elsewhere): copy it. + c.res = new Response(c.res.body, c.res) + c.res.headers.set('x-underlay-collection', id) + } + }) + + // Request budgets (lib/limits.ts): API calls, pages and ARK alike. The page + // renderer's in-process API calls are part of the page view that made them, + // so they don't count again. + app.use('*', async (c, next) => { + const limiter = c.var.ports.rateLimit + if (!limiter || inProcess.has(c.req.raw) || c.req.method === 'OPTIONS') return next() + const p = c.var.principal + const cost = requestCost(c.req.path) + const kind = c.req.path.startsWith('/api/') ? 'anon' : 'page' + const ok = p + ? await limiter.check('user', p.orgId ?? p.userId, cost) + : await limiter.check(kind, clientIp(c.req.raw.headers), cost) + if (ok) return next() + c.header('Retry-After', '60') + return c.json({ error: 'Rate limit exceeded', statusCode: 429 }, 429) + }) + + app.get('/api/health', (c) => + c.json({ + ok: true, + version: 2, + deployment: c.var.config.deployment, + time: new Date().toISOString(), + }), + ) + + app.route('/', arkRoutes()) + // Agent links (web's share panel): a page, budgeted as one above. + app.route('/', agentRoutes()) + // Before the :owner/:slug routes: /api/collections/files/:hash would match them. + app.route('/', recordRoutes()) + app.route('/api/collections', fileRoutes()) + app.route('/api/collections', exportRoutes()) + app.route('/api/collections', pushRoutes()) + app.route('/api/collections', versionRoutes()) + app.route('/api/collections', syncRoutes()) + app.route('/api/collections', webhookRoutes()) + app.route('/', schemaRoutes()) + app.route('/', manageRoutes()) + app.route('/', avatarRoutes()) + app.route('/', locationRoutes()) + // Before collectionRoutes: /api/accounts/me would match /api/accounts/:slug. + app.route('/', accountRoutes()) + app.route('/', adminRoutes()) + app.route('/', cleanupRoutes()) + app.route('/', statsRoutes()) + app.route('/', collectionRoutes()) + + // Everything else is a UI page, when the deployment renders one. + app.get('*', async (c) => { + const { renderPage } = setup(c) + if (!renderPage || c.req.path.startsWith('/api/')) + return c.json({ error: 'Not found', statusCode: 404 }, 404) + // Hono throws reading executionCtx where there is none (Node). + let ctx: ExecutionContext | undefined + try { + ctx = c.executionCtx + } catch { + ctx = undefined + } + const page: PageContext = { principal: c.var.principal, access: new Map() } + return renderPage(c.req.raw, async (req) => { + inProcess.set(req, page) + return app.fetch(req, c.env, ctx) + }) + }) + + app.notFound((c) => c.json({ error: 'Not found', statusCode: 404 }, 404)) + app.onError((err, c) => { + // Per-user caps (push/session.ts) are the caller's to fix, not ours. + if (err.name === 'SessionCapError') { + c.header('Retry-After', '60') + return c.json({ error: err.message, statusCode: 429 }, 429) + } + // Storage cleanup held a write past its patience (cleanup/fence.ts): brief. + if (err.name === 'StorageBusyError' || err.name === 'FenceError') { + c.header('Retry-After', '30') + return c.json({ error: err.message, statusCode: 503 }, 503) + } + console.error('[app]', err) + return c.json({ error: 'Internal error', statusCode: 500 }, 500) + }) + + return app +} + +export type App = ReturnType diff --git a/packages/server/src/auth/auth.ts b/packages/server/src/auth/auth.ts new file mode 100644 index 0000000..681643b --- /dev/null +++ b/packages/server/src/auth/auth.ts @@ -0,0 +1,372 @@ +/** + * Sign-in, sessions, organizations and API keys: better-auth, as in v1, over the + * v2 SQLite schema (D1 or libsql). The tables keep v1's fields so users, + * sessions and keys migrate across unchanged. + * + * KF Auth is the only sign-in method (OIDC via genericOAuth). The `kf_underlay` + * client is shared by www, next and staging; each host needs its callback + * registered in kf-auth (edge-redesign-build.md, finding 14). + * + * D1 has no interactive transactions, so the adapter runs without them. + */ +import { apiKey } from '@better-auth/api-key' +import { betterAuth } from 'better-auth' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { APIError } from 'better-auth/api' +import { genericOAuth } from 'better-auth/plugins' +import { organization } from 'better-auth/plugins/organization' +import { and, eq, inArray, ne } from 'drizzle-orm' + +import type { Principal } from '../api/access.js' +import type { Authenticate } from '../app.js' +import * as schema from '../db/schema.js' +import { defaultOrgSlugCandidate, validateOrgSlug } from '../lib/slug.js' +import type { Db, Ports } from '../ports.js' +import type { Kf } from './kf.js' + +export interface AuthConfig { + appUrl: string + /** better-auth secret (v1's SESSION_SECRET). */ + secret: string + oidc: { + /** Public issuer URL (browser redirects). */ + issuerUrl: string + /** Issuer URL for server-to-server calls; on Workers the same as issuerUrl. */ + internalUrl: string + clientId: string + clientSecret: string + } + /** Extra trusted origins (e.g. http:// variants in dev). */ + trustedOrigins?: string[] +} + +function assertValidSlug(slug: unknown) { + const err = validateOrgSlug(slug) + if (err) throw new APIError('BAD_REQUEST', { message: err }) +} + +export function createAuth( + db: Db, + cfg: AuthConfig, + waitUntil: (p: Promise) => void, + kf?: Kf, +) { + const oidc = cfg.oidc + return betterAuth({ + database: drizzleAdapter(db, { provider: 'sqlite', schema, transaction: false }), + baseURL: cfg.appUrl, + basePath: '/api/auth', + secret: cfg.secret, + trustedOrigins: [cfg.appUrl, ...(cfg.trustedOrigins ?? [])], + advanced: { + database: { generateId: () => crypto.randomUUID() }, + backgroundTasks: { handler: waitUntil }, + }, + plugins: [ + genericOAuth({ + config: [ + { + providerId: 'kf-auth', + authorizationUrl: `${oidc.issuerUrl}/api/auth/oauth2/authorize`, + tokenUrl: `${oidc.internalUrl}/api/auth/oauth2/token`, + userInfoUrl: `${oidc.internalUrl}/api/auth/oauth2/userinfo`, + clientId: oidc.clientId, + clientSecret: oidc.clientSecret, + scopes: ['openid', 'profile', 'email', 'offline_access'], + pkce: true, + mapProfileToUser: (profile) => ({ + name: profile.name ?? profile.email?.split('@')[0] ?? 'User', + email: profile.email, + image: profile.picture ?? null, + }), + }, + ], + }), + organization({ + schema: { + organization: { + additionalFields: { + bio: { type: 'string', required: false, input: true }, + website: { type: 'string', required: false, input: true }, + // Set only by the logo upload (api/avatars.ts), never by a client. + avatarUrl: { type: 'string', required: false, input: false }, + // Server-controlled, as in v1: a caller must not claim another + // institution's NAAN or the default-org flag. + arkNaan: { type: 'string', required: false, input: false }, + // Picked in the new-org form, and checked in the hooks below against + // the caller's own KF orgs: /api/kf/summary trusts it. + kfOrgId: { type: 'string', required: false, input: true }, + isDefault: { type: 'boolean', required: false, input: false, defaultValue: false }, + }, + }, + }, + organizationHooks: { + beforeCreateOrganization: async ({ organization: org, user }) => { + assertValidSlug(org.slug) + // Logo fields would take any URL; logos come from the upload only. + const base = { ...org, logo: null, avatarUrl: null } + const asked = (org as { kfOrgId?: string | null }).kfOrgId + if (asked && kf && (await kf.entitled(user.id, asked))) return { data: base } + // Not one of theirs (or none asked): the server's choice, never the client's. + const kfOrgId = kf ? await kf.defaultOrgId(user.id) : null + return { data: { ...base, kfOrgId: kfOrgId ?? null } } + }, + beforeUpdateOrganization: async ({ organization: org, user }) => { + if (org.slug !== undefined) assertValidSlug(org.slug) + // Clearing is allowed; setting needs membership of that KF org. + const asked = (org as { kfOrgId?: string | null }).kfOrgId + if (!asked || (kf && (await kf.entitled(user.id, asked)))) return + throw new APIError('FORBIDDEN', { message: 'Not a KF organization you belong to' }) + }, + }, + }), + apiKey({ + defaultPrefix: 'ul', + customKeyGenerator: async ({ length }) => { + const { generateRandomString } = await import('better-auth/crypto') + return `ul_${generateRandomString(length, 'a-z', 'A-Z')}` + }, + enableMetadata: true, + // Don't make requests wait on the lastRequest write (v1 setting). + deferUpdates: true, + keyExpiration: { minExpiresIn: 0 }, + rateLimit: { enabled: false }, + permissions: { + defaultPermissions: async (_referenceId, ctx) => { + // A key never acts beyond its holder's role (api/access.ts capRole), so + // the client may ask for any scope: admin keys keep the holder's admin + // powers, write keys act as members. + const scope = ctx.body?.metadata?.scope + if (scope === 'admin') return { collections: ['admin', 'write', 'read'] } + if (scope === 'write') return { collections: ['write', 'read'] } + return { collections: ['read'] } + }, + }, + }), + ], + databaseHooks: { + account: { + create: { + after: async (account) => { + // One account per provider per user (v1). + await db + .delete(schema.account) + .where( + and( + eq(schema.account.userId, account.userId), + eq(schema.account.providerId, account.providerId), + ne(schema.account.id, account.id), + ), + ) + // Link the personal org to the user's KF org. Not at user creation: KF + // Auth's internal API is looked up through this account row. + if (kf && account.providerId === 'kf-auth') { + const kfOrgId = await kf.defaultOrgId(account.userId) + if (kfOrgId) { + const [own] = await db + .select({ id: schema.organization.id, kfOrgId: schema.organization.kfOrgId }) + .from(schema.organization) + .innerJoin( + schema.member, + eq(schema.member.organizationId, schema.organization.id), + ) + .where( + and( + eq(schema.member.userId, account.userId), + eq(schema.organization.isDefault, true), + ), + ) + .limit(1) + if (own && !own.kfOrgId) { + await db + .update(schema.organization) + .set({ kfOrgId }) + .where(eq(schema.organization.id, own.id)) + } + } + } + }, + }, + }, + user: { + create: { + after: async (user) => { + // Every user gets a personal (default) organization, as in v1: the + // first free of 50 candidate slugs, read in one query. + const candidates = Array.from({ length: 50 }, (_, i) => + defaultOrgSlugCandidate(user.email, i), + ) + const taken = new Set( + ( + await db + .select({ slug: schema.organization.slug }) + .from(schema.organization) + .where(inArray(schema.organization.slug, candidates)) + ).map((r) => r.slug), + ) + const slug = + candidates.find((s) => !taken.has(s)) ?? + `${candidates[0]!.replace(/-\d+$/, '')}-${crypto.randomUUID().slice(0, 8)}` + const orgId = crypto.randomUUID() + await db.batch([ + db + .insert(schema.organization) + .values({ id: orgId, name: user.name, slug, isDefault: true }), + db + .insert(schema.member) + .values({ organizationId: orgId, userId: user.id, role: 'owner' }), + ]) + }, + }, + }, + }, + }) +} + +export type Auth = ReturnType + +/** A Bearer key (or ?token=) that isn't a valid key: the request gets 401, not anonymity. */ +export class InvalidKeyError extends Error { + constructor() { + super('Invalid or expired API key') + this.name = 'InvalidKeyError' + } +} + +export const keyConfig = { + /** How long an isolate trusts a key row it read (a revoked key works this much longer). */ + cacheMs: 30_000, + /** `last_request` is written at most this often per key, not on every request. */ + lastRequestEveryMs: 60 * 60 * 1000, +} + +type KeyRow = typeof schema.apikey.$inferSelect +const keyRows = new WeakMap>() + +const b64url = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + +/** better-auth stores JSON columns as strings, older rows twice over. */ +function jsonColumn(v: unknown): T | null { + let out = v + for (let i = 0; i < 2 && typeof out === 'string'; i++) { + try { + out = JSON.parse(out) + } catch { + return null + } + } + return (out as T) ?? null +} + +/** A key's row by its hash (better-auth's: unpadded base64url SHA-256), cached briefly. */ +async function keyRow(db: Db, key: string): Promise { + const hashed = b64url( + new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key))), + ) + let cache = keyRows.get(db) + if (!cache) keyRows.set(db, (cache = new Map())) + const hit = cache.get(hashed) + if (hit && Date.now() - hit.at < keyConfig.cacheMs) return hit.row + const [row] = await db + .select() + .from(schema.apikey) + .where(and(eq(schema.apikey.key, hashed), eq(schema.apikey.configId, 'default'))) + .limit(1) + if (cache.size > 1_000) cache.clear() + cache.set(hashed, { at: Date.now(), row: row ?? null }) + return row ?? null +} + +/** + * Verify an API key against its row directly. better-auth's verifyApiKey writes + * the row on every request (lastRequest, updatedAt), a D1 write per keyed call; + * here `last_request` is written at most hourly. Keys with a usage limit + * (`remaining`) still go through better-auth, which counts them down. + */ +async function verifyKey(auth: Auth, ports: Ports, key: string): Promise { + const row = await keyRow(ports.db, key) + if (!row || row.enabled === false) return null + if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null + let k = row as unknown as { + referenceId: string + permissions?: unknown + metadata?: unknown + } + if (row.remaining !== null) { + const result = await auth.api.verifyApiKey({ body: { key } }).catch(() => null) + if (!result?.valid || !result.key) return null + k = result.key as unknown as typeof k + } else if ( + !row.lastRequest || + Date.now() - row.lastRequest.getTime() > keyConfig.lastRequestEveryMs + ) { + const now = new Date() + row.lastRequest = now + ports.waitUntil( + ports.db + .update(schema.apikey) + .set({ lastRequest: now }) + .where(eq(schema.apikey.id, row.id)) + .then(() => {}) + .catch((err: unknown) => console.error('[auth] lastRequest write failed', err)), + ) + } + const perms = jsonColumn>(k.permissions)?.collections ?? [] + const scope = perms.includes('admin') ? 'admin' : perms.includes('write') ? 'write' : 'read' + const metadata = jsonColumn<{ collectionIds?: string[] }>(k.metadata) + const [org] = await ports.db + .select({ id: schema.organization.id }) + .from(schema.organization) + .where(eq(schema.organization.id, k.referenceId)) + .limit(1) + return { + userId: k.referenceId, + scope, + collectionIds: metadata?.collectionIds?.length ? metadata.collectionIds : null, + ...(org ? { orgId: org.id } : {}), + } +} + +/** + * The request authenticator: API keys (Bearer, or ?token= on GET for share + * links), then the session cookie. A key that doesn't verify throws + * InvalidKeyError (401), rather than passing as anonymous. + */ +export function authenticator(getAuth: (ports: Ports) => Auth): Authenticate { + return async (req, ports) => { + const auth = getAuth(ports) + const bearer = req.headers.get('authorization') + const queryToken = + req.method === 'GET' || req.method === 'HEAD' + ? new URL(req.url).searchParams.get('token') + : null + // The scheme is case-insensitive (RFC 9110 §11.1). `Bearer` with no token + // (headers arrive trimmed) is a key that doesn't verify, not anonymity. + const scheme = bearer && /^bearer(\s+|$)/i.exec(bearer) + const key = scheme ? bearer.slice(scheme[0].length).trim() : queryToken + if (scheme && !key) throw new InvalidKeyError() + if (key) { + const principal = await verifyKey(auth, ports, key) + if (!principal) throw new InvalidKeyError() + return principal + } + try { + const session = await auth.api.getSession({ headers: req.headers }) + if (session) { + return { + userId: session.user.id, + scope: 'session', + collectionIds: null, + sessionId: session.session.id, + } + } + } catch { + // No session. + } + return null + } +} diff --git a/packages/server/src/auth/kf.ts b/packages/server/src/auth/kf.ts new file mode 100644 index 0000000..7429380 --- /dev/null +++ b/packages/server/src/auth/kf.ts @@ -0,0 +1,282 @@ +/** + * KF Auth beyond sign-in: a user's profile and role (OIDC userinfo with their + * stored token, refreshed when it expires) and, when the deployment has the + * service key, the internal API for the KF orgs a user belongs to. Ported from + * v1's `src/lib/auth.server.ts` and `src/lib/auth-internal.server.ts`. + * + * Every call fails soft: KF Auth being down costs a role or an org list, never + * a page. + * + * Refreshes are single-flight per user in an isolate: KF Auth rotates refresh + * tokens, so of two refreshes with the same token the second fails. One in + * another isolate can still lose that race, so a failed refresh reads the + * account row again for the token the winner stored. + */ +import { and, eq } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import type { Db } from '../ports.js' + +export interface KfConfig { + /** Issuer URL for server-to-server calls. */ + internalUrl: string + clientId: string + clientSecret: string + /** Shared secret for KF Auth's internal API (`AUTH_INTERNAL_API_KEY`); optional. */ + internalApiKey?: string | undefined +} + +export interface KfProfile { + name: string | null + image: string | null + /** 'admin' for stewards. */ + role: string | null +} + +export interface KfOrg { + id: string + name: string + slug: string + type: 'personal' | 'shared' + role: string +} + +export interface Kf { + /** Profile for the app shell; cached for 30 s per isolate. */ + profile(userId: string): Promise + /** + * The user's KF role, read fresh: for authorization. When KF Auth can't be + * read, the role read in the last 30 s. + */ + role(userId: string): Promise + /** KF orgs the user belongs to; [] without the internal API. */ + orgs(userId: string): Promise + /** Whether the user belongs to KF org `kfOrgId`. */ + entitled(userId: string, kfOrgId: string): Promise + /** The user's personal KF org (else their first), for new orgs. */ + defaultOrgId(userId: string): Promise + /** Whether an Authorization header carries the internal API key. */ + isInternalCall(authorization: string | undefined): boolean +} + +const PROFILE_TTL_MS = 30_000 +const TIMEOUT_MS = 5_000 + +export interface KfOptions { + /** How long a failed refresh waits before it reads the account row again. */ + rereadDelayMs?: number +} + +type AccountRow = typeof schema.account.$inferSelect + +/** A stored access token good for at least 30 s more. */ +const usable = (acct: AccountRow) => + acct.accessToken && (acct.accessTokenExpiresAt?.getTime() ?? 0) > Date.now() + 30_000 + ? acct.accessToken + : null + +export function createKf( + db: Db, + cfg: KfConfig, + fetcher: typeof fetch = fetch, + opts: KfOptions = {}, +): Kf { + const rereadDelayMs = opts.rereadDelayMs ?? 500 + const profiles = new Map() + /** Refreshes in flight, by user. */ + const refreshing = new Map>() + const call = (url: string, init: RequestInit = {}) => + fetcher(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) }) + + const account = async (userId: string): Promise => { + const [acct] = await db + .select() + .from(schema.account) + .where(and(eq(schema.account.userId, userId), eq(schema.account.providerId, 'kf-auth'))) + .limit(1) + return acct ?? null + } + + async function accessToken(userId: string): Promise { + const acct = await account(userId) + if (!acct) return null + const token = usable(acct) + if (token) return token + if (!acct.refreshToken) return null + let flight = refreshing.get(userId) + if (!flight) { + flight = refresh(userId, acct).finally(() => refreshing.delete(userId)) + refreshing.set(userId, flight) + } + return flight + } + + /** Refresh the token; when that fails, the token another refresh stored meanwhile. */ + async function refresh(userId: string, acct: AccountRow): Promise { + const token = await refreshWith(acct) + if (token) return token + await new Promise((r) => setTimeout(r, rereadDelayMs)) + const again = await account(userId) + const stored = again && usable(again) + if (stored) return stored + console.warn(`[kf] no usable token for user ${userId} after a failed refresh`) + return null + } + + async function refreshWith(acct: AccountRow): Promise { + const res = await call(`${cfg.internalUrl}/api/auth/oauth2/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: acct.refreshToken ?? '', + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + }), + }).catch((err: unknown) => { + console.warn(`[kf] token refresh for user ${acct.userId} failed:`, String(err)) + return null + }) + if (!res) return null + if (!res.ok) { + const body = await res.text().catch(() => '') + console.warn( + `[kf] token refresh for user ${acct.userId} failed: ${res.status} ${body.slice(0, 200)}`, + ) + return null + } + const tokens = (await res.json()) as { + access_token?: string + refresh_token?: string + expires_in?: number + } + if (!tokens.access_token) return null + await db + .update(schema.account) + .set({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? acct.refreshToken, + accessTokenExpiresAt: tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000) + : null, + updatedAt: new Date(), + }) + .where(eq(schema.account.id, acct.id)) + return tokens.access_token + } + + async function fetchProfile(userId: string): Promise { + try { + const token = await accessToken(userId) + if (!token) return null + const res = await call(`${cfg.internalUrl}/api/auth/oauth2/userinfo`, { + headers: { authorization: `Bearer ${token}` }, + }) + if (!res.ok) { + console.warn(`[kf] userinfo for user ${userId} failed: ${res.status}`) + return null + } + const p = (await res.json()) as Record + const str = (v: unknown) => (typeof v === 'string' ? v : null) + return { + name: str(p.name), + image: str(p.picture), + role: str(p['https://knowledgefutures.org/role']) ?? str(p.role), + } + } catch (err) { + console.warn(`[kf] profile for user ${userId} failed:`, String(err)) + return null + } + } + + async function internal(path: string): Promise { + if (!cfg.internalApiKey) return null + try { + const res = await call(`${cfg.internalUrl}/api/internal${path}`, { + headers: { authorization: `Bearer ${cfg.internalApiKey}` }, + }) + return res.ok ? ((await res.json()) as T) : null + } catch { + return null + } + } + + async function orgsOf(kfUserId: string): Promise { + const data = await internal<{ orgs?: KfOrg[] } | KfOrg[]>(`/users/${kfUserId}/orgs`) + return Array.isArray(data) ? data : (data?.orgs ?? []) + } + + /** A KF user id by exact email: the search also matches names and hides emails. */ + async function kfUserByEmail(email: string): Promise { + const found = await internal<{ users?: { id: string }[] }>( + `/users/search?q=${encodeURIComponent(email)}`, + ) + const wanted = email.trim().toLowerCase() + for (const u of found?.users ?? []) { + const full = await internal<{ email?: string }>(`/users/${u.id}`) + if (full?.email?.trim().toLowerCase() === wanted) return u.id + } + return null + } + + const kf: Kf = { + async profile(userId) { + const hit = profiles.get(userId) + if (hit && Date.now() - hit.at < PROFILE_TTL_MS) return hit.profile + const profile = await fetchProfile(userId) + if (profile) profiles.set(userId, { profile, at: Date.now() }) + return profile + }, + async role(userId) { + const profile = await fetchProfile(userId) + if (profile) { + profiles.set(userId, { profile, at: Date.now() }) + return profile.role + } + // KF Auth couldn't say: the role read in the last 30 s, if any. + const hit = profiles.get(userId) + return hit && Date.now() - hit.at < PROFILE_TTL_MS ? hit.profile.role : null + }, + async orgs(userId) { + if (!cfg.internalApiKey) return [] + const [acct] = await db + .select({ accountId: schema.account.accountId }) + .from(schema.account) + .where(and(eq(schema.account.userId, userId), eq(schema.account.providerId, 'kf-auth'))) + .limit(1) + if (!acct) return [] + let orgs = await orgsOf(acct.accountId) + if (orgs.length === 0) { + const [u] = await db + .select({ email: schema.user.email }) + .from(schema.user) + .where(eq(schema.user.id, userId)) + .limit(1) + const kfUserId = u?.email ? await kfUserByEmail(u.email) : null + if (kfUserId) orgs = await orgsOf(kfUserId) + } + return orgs + }, + async entitled(userId, kfOrgId) { + return (await kf.orgs(userId)).some((o) => o.id === kfOrgId) + }, + async defaultOrgId(userId) { + const orgs = await kf.orgs(userId) + return (orgs.find((o) => o.type === 'personal') ?? orgs[0])?.id ?? null + }, + isInternalCall(authorization) { + return ( + !!cfg.internalApiKey && timingSafeEqual(authorization ?? '', `Bearer ${cfg.internalApiKey}`) + ) + }, + } + return kf +} + +function timingSafeEqual(a: string, b: string): boolean { + const x = new TextEncoder().encode(a) + const y = new TextEncoder().encode(b) + let diff = x.length ^ y.length + for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0) + return diff === 0 +} diff --git a/packages/server/src/billing/reconcile.ts b/packages/server/src/billing/reconcile.ts new file mode 100644 index 0000000..b7b08d3 --- /dev/null +++ b/packages/server/src/billing/reconcile.ts @@ -0,0 +1,467 @@ +/** + * Reconcile: every billing counter is a cache of something durable, rebuilt here + * from that source, compared, reported and overwritten (edge-redesign.md, + * "Metering, and rebuilding the counters"; decision 21). + * + * A run is one chain of `reconcile.step` jobs per collection, so it never floods + * the bulk queue, and each job handles a bounded number of versions: + * + * versions stage each version's totals from its root, and its reference-log + * events and bytes from re-diffing it against the one before. + * Versions are immutable, so a scheduled run checks only those + * never checked; a steward's run (`full`) checks them all. + * collection stage schema usage replayed from every root (one or two reads a + * version), and the cumulative public files tree rebuilt from + * the last run's checkpoint by diffing consecutive versions' + * public file trees: O(changes since then), not O(history). + * Then the reference counters as a sum over the versions. + * + * The run's progress is `collections.reconcile_state`, advanced by a step number + * so a duplicate delivery can't fork the chain. A run is due weekly (the cron + * sweep starts a few at a time) and stewards can start one (POST + * /api/admin/reconcile). Differences are logged and kept as the version's or + * collection's `reconcile_report`; normal operation never makes any, since the + * counters are written in the publish batch. + */ +import { + compareUtf8, + diffTrees, + type FileEntry, + fileTree, + mergeTree, + RepoSink, + RepoSource, + setRecordTotals, +} from '@underlay/protocol' +import { and, asc, eq, gt, gte, isNotNull, isNull, lt, or, sql } from 'drizzle-orm' + +import { fenceHolds, fenceMoved, writeFence } from '../cleanup/fence.js' +import { chunks } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import type { Ports } from '../ports.js' +import { estimatedEvents, versionEvents } from '../refs/log.js' +import { eventBytes } from '../refs/segments.js' + +type Diff = schema.ReconcileDiff +type State = schema.ReconcileState +type Usage = schema.UsageSpan +type VersionRow = typeof schema.versions.$inferSelect +type CollectionRow = typeof schema.collections.$inferSelect + +export const reconcileConfig = { + /** How often each collection is reconciled. */ + everyMs: 7 * 24 * 60 * 60 * 1000, + /** Runs the sweep starts at once. */ + perSweep: 20, + /** A run not finished after this is started again. */ + staleMs: 24 * 60 * 60 * 1000, + /** Versions one job checks, or folds into the collection's totals. */ + versionsPerJob: 100, + /** Events one job re-derives, about (a version's diff against the one before). */ + eventsPerJob: 1_000_000, + /** + * A version with more events than this isn't re-diffed: one job couldn't. Its + * totals are still checked, and its indexed counts stand. + */ + maxRecountEvents: 5_000_000, +} + +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) + +/** What a version's row should say, from its root. */ +async function versionTotals(ports: Ports, v: VersionRow) { + const repo = await ports.stores.forCollection(v.collectionId) + const root = await repo.root(v.hash) + const priv = root.private ? await repo.privateSet(root.private) : null + const pub = setRecordTotals(root.public) + const pri = priv ? setRecordTotals(priv) : { count: 0, bytes: 0 } + const typeCounts: Record = {} + const publicTypeCounts: Record = {} + for (const [slug, t] of Object.entries(root.public.types)) { + typeCounts[slug] = (typeCounts[slug] ?? 0) + t.count + publicTypeCounts[slug] = t.count + } + for (const [slug, t] of Object.entries(priv?.types ?? {})) + typeCounts[slug] = (typeCounts[slug] ?? 0) + t.count + const pubFiles = root.public.files + const privFiles = priv?.files ?? { count: 0, bytes: 0 } + return { + recordCount: pub.count + pri.count, + publicRecordCount: pub.count, + fileCount: pubFiles.count + privFiles.count, + totalBytes: pub.bytes + pri.bytes + pubFiles.bytes + privFiles.bytes, + publicFileCount: pubFiles.count, + publicTotalBytes: pub.bytes + pubFiles.bytes, + typeCounts, + publicTypeCounts, + hasPrivate: root.private !== null, + } +} + +/** Whether a collection's first version is a fork's copy (it writes no events). */ +async function isForkStart(ports: Ports, v: VersionRow) { + if (v.seq !== 1) return false + const [f] = await ports.db + .select({ id: schema.forks.childCollectionId }) + .from(schema.forks) + .where(eq(schema.forks.childCollectionId, v.collectionId)) + return !!f +} + +/** + * Reconcile one version's row. Returns what it corrected. A version whose events + * aren't indexed yet stays unchecked (reconciled_at null), so a later run checks + * its events once they are. + */ +export async function reconcileVersion(ports: Ports, v: VersionRow): Promise { + const { db } = ports + const diffs: Diff[] = [] + const want: Partial = {} + for (const [field, value] of Object.entries(await versionTotals(ports, v))) { + const was = v[field as keyof VersionRow] + if (!same(was, value)) { + diffs.push({ field, seq: v.seq, was, now: value }) + Object.assign(want, { [field]: value }) + } + } + if (v.refsIndexed && estimatedEvents(v) <= reconcileConfig.maxRecountEvents) { + let events = 0 + let bytes = 0 + if (!(await isForkStart(ports, v))) { + for await (const e of versionEvents(ports, v)) { + events++ + bytes += eventBytes(e) + } + } + // Rows indexed before 0011 have no counts yet: filling them in isn't a correction. + if (v.refEvents !== null && v.refEvents !== events) + diffs.push({ field: 'refEvents', seq: v.seq, was: v.refEvents, now: events }) + if (v.refBytes !== null && v.refBytes !== bytes) + diffs.push({ field: 'refBytes', seq: v.seq, was: v.refBytes, now: bytes }) + Object.assign(want, { refEvents: events, refBytes: bytes }) + } + if (diffs.length) console.error(`[reconcile] version ${v.id} (seq ${v.seq}):`, diffs) + await db + .update(schema.versions) + .set({ + ...want, + reconciledAt: v.refsIndexed ? new Date() : null, + reconcileReport: diffs.length ? diffs : null, + }) + .where(eq(schema.versions.id, v.id)) + return diffs +} + +/** Start (or restart) a run for a collection. */ +export async function startReconcile( + ports: Ports, + collectionId: string, + opts: { full?: boolean } = {}, +): Promise { + const state: State = { step: 1, full: opts.full === true, stage: 'versions', afterSeq: 0 } + const won = await ports.db + .update(schema.collections) + .set({ reconcileStartedAt: new Date(), reconcileState: state }) + .where(eq(schema.collections.id, collectionId)) + .returning({ id: schema.collections.id }) + if (!won.length) return false + await ports.jobs.enqueue({ type: 'reconcile.step', collectionId, step: 1 }) + return true +} + +/** The collection stage from its start: a fresh fence, files from the checkpoint. */ +async function collectionStart(ports: Ports, c: CollectionRow, s: State): Promise { + const fromCheckpoint = !s.full && c.reconciledSeq > 0 + return { + step: s.step, + full: s.full, + stage: 'collection', + afterSeq: 0, + fence: await writeFence(ports.db), + filesFrom: fromCheckpoint ? c.reconciledSeq : 0, + filesRoot: fromCheckpoint ? c.reconciledFilesRoot : null, + prevFiles: null, + usage: [], + } +} + +/** Check the next versions; then on to the collection. */ +async function versionsStage(ports: Ports, c: CollectionRow, s: State): Promise { + const rows = await ports.db + .select() + .from(schema.versions) + .where( + and( + eq(schema.versions.collectionId, c.id), + gt(schema.versions.seq, s.afterSeq), + s.full ? undefined : isNull(schema.versions.reconciledAt), + ), + ) + .orderBy(asc(schema.versions.seq)) + .limit(reconcileConfig.versionsPerJob) + let events = 0 + for (const [i, v] of rows.entries()) { + await reconcileVersion(ports, v) + events += estimatedEvents(v) + if (events >= reconcileConfig.eventsPerJob && i < rows.length - 1) + return { ...s, afterSeq: v.seq } + } + if (rows.length === reconcileConfig.versionsPerJob) return { ...s, afterSeq: rows.at(-1)!.seq } + return collectionStart(ports, c, s) +} + +/** Fold the next versions into the replayed usage and the rebuilt files tree. */ +async function foldVersions(ports: Ports, c: CollectionRow, s: State, rows: VersionRow[]) { + const repo = await ports.stores.forCollection(c.id) + const source = new RepoSource(fileTree, repo) + const usage = (s.usage ?? []).map((u) => ({ ...u })) + const filesFrom = s.filesFrom ?? 0 + let prevFiles = s.prevFiles ?? null + const added = new Map() + for (const v of rows) { + const root = await repo.root(v.hash) + const priv = root.private ? await repo.privateSet(root.private) : null + const now = new Map() + for (const [slug, t] of Object.entries(root.public.types)) + now.set(`public\u0000${slug}`, t.schema) + for (const [slug, t] of Object.entries(priv?.types ?? {})) + now.set(`private\u0000${slug}`, t.schema) + const open = new Map( + usage.filter((u) => u.toSeq === null).map((u) => [`${u.set}\u0000${u.typeSlug}`, u]), + ) + for (const [k, u] of open) if (now.get(k) !== u.schemaHash) u.toSeq = v.seq + for (const [k, hash] of now) { + const o = open.get(k) + if (o && o.toSeq === null) continue + const [set, typeSlug] = k.split('\u0000') as ['public' | 'private', string] + usage.push({ schemaHash: hash, typeSlug, set, fromSeq: v.seq, toSeq: null }) + } + // Files that entered the public set since the version before. + const files = root.public.files.root + if (v.seq > filesFrom) { + for await (const d of diffTrees(source, prevFiles, files)) + if (d.after && !d.before) added.set(d.key, d.after) + } + prevFiles = files + } + let filesRoot = s.filesRoot ?? null + if (added.size) { + const sink = new RepoSink(repo) + const merged = await mergeTree( + source, + sink, + filesRoot, + [...added.values()] + .sort((a, b) => compareUtf8(a.key, b.key)) + .map((e) => ({ key: e.key, entry: e })), + ) + await sink.flush() + filesRoot = merged.root?.hash ?? null + } + return { ...s, afterSeq: rows.at(-1)?.seq ?? s.afterSeq, prevFiles, filesRoot, usage } +} + +const usageKey = (u: Usage) => + `${u.set}|${u.typeSlug}|${u.fromSeq}|${u.toSeq ?? ''}|${u.schemaHash}` + +/** + * The collection stage: fold versions in a job at a time; at the head, compare + * and write. Returns null when the run is finished, else the next state. + */ +async function collectionStage(ports: Ports, c: CollectionRow, s: State): Promise { + const { db } = ports + const rows = await db + .select() + .from(schema.versions) + .where(and(eq(schema.versions.collectionId, c.id), gt(schema.versions.seq, s.afterSeq))) + .orderBy(asc(schema.versions.seq)) + .limit(reconcileConfig.versionsPerJob) + const folded = rows.length ? await foldVersions(ports, c, s, rows) : s + if (rows.length === reconcileConfig.versionsPerJob) return folded + + // At the head, unless a publish moved it meanwhile (then fold that in next). + const [head] = c.headVersionId + ? await db + .select({ seq: schema.versions.seq }) + .from(schema.versions) + .where(eq(schema.versions.id, c.headVersionId)) + : [] + const headSeq = head?.seq ?? 0 + if (headSeq !== folded.afterSeq) return folded + + const diffs: Diff[] = [] + const set: Partial = {} + + // Reference-log counters: the sum over indexed versions. History totals: over all. + const [sums] = (await db.all(sql` + SELECT coalesce(sum(CASE WHEN refs_indexed = 1 THEN ref_events END), 0) AS events, + coalesce(sum(CASE WHEN refs_indexed = 1 THEN ref_bytes END), 0) AS bytes, + count(*) AS versions, coalesce(sum(total_bytes), 0) AS historyBytes, + max(created_at) AS lastPushAt + FROM ${schema.versions} WHERE collection_id = ${c.id} + `)) as { + events: number + bytes: number + versions: number + historyBytes: number + lastPushAt: number | null + }[] + const refEvents = Number(sums?.events ?? 0) + const refBytes = Number(sums?.bytes ?? 0) + const history = { + versionCount: Number(sums?.versions ?? 0), + historyBytes: Number(sums?.historyBytes ?? 0), + lastPushAt: sums?.lastPushAt == null ? null : Number(sums.lastPushAt), + } + for (const [field, now] of Object.entries(history)) { + const was = + field === 'lastPushAt' ? (c.lastPushAt?.getTime() ?? null) : c[field as 'versionCount'] + if (was !== now) { + diffs.push({ field, was, now }) + Object.assign(set, { [field]: field === 'lastPushAt' && now !== null ? new Date(now) : now }) + } + } + if (c.refEvents !== refEvents) { + diffs.push({ field: 'refEvents', was: c.refEvents, now: refEvents }) + set.refEvents = refEvents + } + if (c.refBytes !== refBytes) { + diffs.push({ field: 'refBytes', was: c.refBytes, now: refBytes }) + set.refBytes = refBytes + } + + // Schema usage. + const rebuilt = folded.usage ?? [] + const stored = await db + .select() + .from(schema.schemaUsage) + .where(eq(schema.schemaUsage.collectionId, c.id)) + const was = stored.map(usageKey).sort() + const now = rebuilt.map(usageKey).sort() + const usageDiffers = !same(was, now) + if (usageDiffers) diffs.push({ field: 'schemaUsage', was, now }) + + // The cumulative public files tree. + const filesRoot = folded.filesRoot ?? null + if (filesRoot !== c.publicFilesRoot) { + diffs.push({ field: 'publicFilesRoot', was: c.publicFilesRoot, now: filesRoot }) + set.publicFilesRoot = filesRoot + } + + const versionDiffs = ( + await db + .select({ report: schema.versions.reconcileReport }) + .from(schema.versions) + .where( + and(eq(schema.versions.collectionId, c.id), isNotNull(schema.versions.reconcileReport)), + ) + .limit(100) + ).flatMap((r) => r.report ?? []) + const report = [...versionDiffs, ...diffs] + if (diffs.length) console.error(`[reconcile] collection ${c.id}:`, diffs) + // The tree's nodes were written across this stage's jobs: the write holds only + // under the fence read when it began, and only for the head it was built to. + await db.batch([ + ...(usageDiffers + ? [ + db.delete(schema.schemaUsage).where(eq(schema.schemaUsage.collectionId, c.id)), + ...chunks(rebuilt, 14).map((part) => + db.insert(schema.schemaUsage).values(part.map((u) => ({ ...u, collectionId: c.id }))), + ), + ] + : []), + db + .update(schema.collections) + .set({ + ...set, + reconciledAt: new Date(), + reconcileReport: report.length ? report : null, + reconcileState: null, + reconciledSeq: headSeq, + reconciledFilesRoot: filesRoot, + }) + .where( + and( + eq(schema.collections.id, c.id), + c.headVersionId + ? eq(schema.collections.headVersionId, c.headVersionId) + : isNull(schema.collections.headVersionId), + sql`json_extract(${schema.collections.reconcileState}, '$.step') = ${s.step}`, + fenceHolds(s.fence ?? -1), + ), + ), + ] as unknown as Parameters[0]) + const [after] = await db + .select({ state: schema.collections.reconcileState }) + .from(schema.collections) + .where(eq(schema.collections.id, c.id)) + if (!after || after.state === null) return null + // A deletion window opened: the nodes built so far may be gone, so start the stage again. + if (await fenceMoved(db, s.fence ?? -1)) return collectionStart(ports, c, s) + return folded // the head moved: fold the new versions in +} + +/** One job of a run. */ +export async function reconcileStep(ports: Ports, collectionId: string, step: number) { + const { db } = ports + const [c] = await db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, collectionId)) + const s = c?.reconcileState + if (!c || !s || s.step !== step) return // finished, restarted, or a duplicate delivery + const next = + s.stage === 'versions' ? await versionsStage(ports, c, s) : await collectionStage(ports, c, s) + if (!next) return + const won = await db + .update(schema.collections) + .set({ reconcileState: { ...next, step: step + 1 } }) + .where( + and( + eq(schema.collections.id, collectionId), + sql`json_extract(${schema.collections.reconcileState}, '$.step') = ${step}`, + ), + ) + .returning({ id: schema.collections.id }) + if (won.length) await ports.jobs.enqueue({ type: 'reconcile.step', collectionId, step: step + 1 }) +} + +/** Start the runs that are due (the cron sweep). */ +export async function reconcileDue(ports: Ports): Promise { + const now = Date.now() + const due = await ports.db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where( + and( + or( + isNull(schema.collections.reconciledAt), + lt(schema.collections.reconciledAt, new Date(now - reconcileConfig.everyMs)), + ), + or( + isNull(schema.collections.reconcileStartedAt), + lt(schema.collections.reconcileStartedAt, new Date(now - reconcileConfig.staleMs)), + // Finished since it last started: due again only by reconciledAt above. + gte(schema.collections.reconciledAt, schema.collections.reconcileStartedAt), + ), + ), + ) + .orderBy( + sql`${schema.collections.reconciledAt} IS NOT NULL`, + asc(schema.collections.reconciledAt), + ) + .limit(reconcileConfig.perSweep) + for (const c of due) await startReconcile(ports, c.id) + return due.length +} + +registerJob('reconcile.collection', async (job, ports) => { + await startReconcile(ports, String(job.collectionId), { full: job.full === true }) +}) +registerJob('reconcile.step', async (job, ports) => { + await reconcileStep(ports, String(job.collectionId), Number(job.step)) +}) +// Jobs of runs started before migration 0020; the sweep restarts those runs when they go stale. +registerJob('reconcile.version', async () => {}) +registerJob('reconcile.finish', async () => {}) diff --git a/packages/server/src/billing/usage.ts b/packages/server/src/billing/usage.ts new file mode 100644 index 0000000..67c5feb --- /dev/null +++ b/packages/server/src/billing/usage.ts @@ -0,0 +1,317 @@ +/** + * The usage log (edge-redesign.md, "Metering, and rebuilding the counters"): + * what requests use, which version data can't tell. API calls and response + * bytes per collection, and file downloads and their bytes. + * + * An isolate's events travel together (a queue message every few seconds on + * Workers; a buffer on Node) and land in immutable `usage//.ndjson` + * objects in the internal area, a batch at a time. An object is named by the + * hash of its contents, so a redelivered batch rewrites the same object and the + * log never counts it twice. `usage_rollups` are added to as each batch lands; + * there a redelivered batch can count twice, until `usage.rebuild` recomputes + * the day from the log, a page of log objects per job. The cron sweep queues + * that for each day once it's over; stewards can run it for any day. Keep the log for at + * least the billing dispute window. + */ +import { and, eq, sql } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import type { Ports } from '../ports.js' + +export type UsageMetric = 'api_calls' | 'response_bytes' | 'file_downloads' | 'file_bytes' + +/** One event: request id and position (the dedupe key), time, who is billed, what for. */ +export interface UsageEvent { + r: string + i: number + t: number + /** The account billed: the organization owning the collection. */ + a: string + c: string | null + m: UsageMetric + n: number +} + +/** A request's usage while it runs: who it bills, and its events so far. */ +export interface Meter { + requestId: string + /** The collection a route resolved (requireCollection), and its owning account. */ + collection: { id: string; accountId: string } | null + events: UsageEvent[] + /** + * Set by a route whose body is compressed storage (records.ndjson.gz): the + * uncompressed bytes it sent, billed instead of the wire bytes, so the same + * data costs the same whichever form it's read in. + */ + logicalBytes?: number +} + +export const newMeter = (): Meter => ({ + requestId: crypto.randomUUID(), + collection: null, + events: [], +}) + +/** Add an event to a request's meter, billed to `on` (default: the request's collection). */ +export function meter( + m: Meter, + metric: UsageMetric, + n: number, + on: { id: string; accountId: string } | null = m.collection, +): void { + if (!on || n <= 0) return + m.events.push({ + r: m.requestId, + i: m.events.length, + t: Date.now(), + a: on.accountId, + c: on.id, + m: metric, + n, + }) +} + +/** Where a request's events go. */ +export interface UsageSink { + record(events: UsageEvent[]): void +} + +const PREFIX = 'usage' +const dayOf = (t: number) => new Date(t).toISOString().slice(0, 10) + +/** Write one batch of events: a log object per day it spans, then rollup increments. */ +export async function writeUsage(ports: Ports, events: UsageEvent[]): Promise { + if (events.length === 0) return + const byDay = new Map() + for (const e of events) { + const d = dayOf(e.t) + byDay.set(d, [...(byDay.get(d) ?? []), e]) + } + for (const [day, list] of byDay) { + // Log first: a rollup never counts what the log lacks. + const body = list.map((e) => JSON.stringify(e)).join('\n') + '\n' + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)) + const hash = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') + await ports.stores.internal.put(`${PREFIX}/${day}/${hash}.ndjson`, body, { + contentType: 'application/x-ndjson', + }) + await addRollups(ports, day, sum(list)) + } +} + +type Totals = Map< + string, + { accountId: string; collectionId: string; metric: string; amount: number } +> + +function sum(events: UsageEvent[]): Totals { + const out: Totals = new Map() + for (const e of events) { + const k = `${e.a}\u0000${e.c ?? ''}\u0000${e.m}` + const cur = out.get(k) ?? { accountId: e.a, collectionId: e.c ?? '', metric: e.m, amount: 0 } + cur.amount += e.n + out.set(k, cur) + } + return out +} + +async function addRollups(ports: Ports, day: string, totals: Totals) { + const { db } = ports + const rows = [...totals.values()] + // 5 bound parameters a row: 20 rows a statement stays under D1's 100. + for (let i = 0; i < rows.length; i += 20) { + await db + .insert(schema.usageRollups) + .values(rows.slice(i, i + 20).map((r) => ({ day, ...r }))) + .onConflictDoUpdate({ + target: [ + schema.usageRollups.day, + schema.usageRollups.accountId, + schema.usageRollups.collectionId, + schema.usageRollups.metric, + ], + set: { amount: sql`${schema.usageRollups.amount} + excluded.amount` }, + }) + } +} + +/** A day's rebuild in progress: where the listing is, and the totals so far. */ +interface RebuildState { + step: number + cursor: string | null + events: number + totals: [string, { accountId: string; collectionId: string; metric: string; amount: number }][] +} + +const rebuildKey = (day: string) => `usage-rebuild/${day}.json` + +/** + * One job of a day's rebuild: a page of log objects (a listing page, up to + * 1,000) summed into the running totals, kept in the internal area between + * jobs. Memory holds the totals (one per account, collection and metric), not + * the events. The last page replaces the day's rollups. Returns the next step, + * or null when done. + */ +export async function rebuildUsageStep( + ports: Ports, + day: string, + step: number, +): Promise { + const store = ports.stores.internal + const saved = step === 0 ? null : await store.get(rebuildKey(day)) + const state: RebuildState = saved + ? (JSON.parse(await saved.text()) as RebuildState) + : { step: 0, cursor: null, events: 0, totals: [] } + if (state.step !== step) return null // a duplicate delivery + const totals: Totals = new Map(state.totals) + const page = await store.list(`${PREFIX}/${day}/`, state.cursor ?? undefined) + for (const key of page.keys) { + const obj = await store.get(key) + if (!obj) continue + const events = (await obj.text()) + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l) as UsageEvent) + state.events += events.length + for (const [k, t] of sum(events)) { + const cur = totals.get(k) + if (cur) cur.amount += t.amount + else totals.set(k, t) + } + } + if (page.cursor) { + const next: RebuildState = { + step: step + 1, + cursor: page.cursor, + events: state.events, + totals: [...totals], + } + await store.put(rebuildKey(day), JSON.stringify(next), { contentType: 'application/json' }) + return step + 1 + } + await ports.db.delete(schema.usageRollups).where(eq(schema.usageRollups.day, day)) + await addRollups(ports, day, totals) + await store.delete(rebuildKey(day)).catch(() => {}) + return null +} + +/** Recompute one day's rollups from the log, all its steps in this call (tests, small days). */ +export async function rebuildUsageDay(ports: Ports, day: string): Promise { + let events = 0 + const store = ports.stores.internal + let cursor: string | undefined + do { + const page = await store.list(`${PREFIX}/${day}/`, cursor) + for (const key of page.keys) { + const obj = await store.get(key) + if (obj) events += (await obj.text()).split('\n').filter(Boolean).length + } + cursor = page.cursor + } while (cursor) + for (let step: number | null = 0; step !== null;) step = await rebuildUsageStep(ports, day, step) + return events +} + +/** A day's rollups for an account (or every account). */ +export async function usageFor(ports: Ports, day: string, accountId?: string) { + return ports.db + .select() + .from(schema.usageRollups) + .where( + and( + eq(schema.usageRollups.day, day), + accountId ? eq(schema.usageRollups.accountId, accountId) : undefined, + ), + ) +} + +export const usageBatch = { + /** How long an isolate holds events before sending them. */ + flushMs: 5_000, + /** Events that go at once (a queue message is at most 128 KB; an event is ~150 bytes). */ + maxEvents: 400, +} + +/** An isolate's unsent events (Workers): module state, shared by its requests. */ +export interface UsageBuffer { + events: UsageEvent[] + scheduled: boolean +} + +export const newUsageBuffer = (): UsageBuffer => ({ events: [], scheduled: false }) + +/** + * Workers: an isolate's events go out together, one queue message per few + * seconds instead of one per request. The first event into an empty buffer + * schedules a send `flushMs` later on its request's waitUntil; a full buffer + * goes at once. A send that fails is retried, then written straight to the log + * (`fallback`), so a queue outage doesn't lose usage. An isolate evicted with + * events still waiting loses them: at most `flushMs` of one isolate's. + */ +export function isolateUsageSink( + buffer: UsageBuffer, + waitUntil: (p: Promise) => void, + send: (events: UsageEvent[]) => Promise, + fallback: (events: UsageEvent[]) => Promise, +): UsageSink { + const flush = async () => { + const out = buffer.events.splice(0, usageBatch.maxEvents) + if (out.length === 0) return + try { + await send(out).catch(() => send(out)) + } catch (err) { + console.error('[usage] send failed twice; writing the batch directly', err) + await fallback(out).catch((e: unknown) => console.error('[usage] batch lost', e)) + } + } + return { + record(events) { + if (events.length === 0) return + buffer.events.push(...events) + if (buffer.events.length >= usageBatch.maxEvents) { + waitUntil(flush()) + } else if (!buffer.scheduled) { + buffer.scheduled = true + waitUntil( + new Promise((r) => setTimeout(r, usageBatch.flushMs)).then(async () => { + buffer.scheduled = false + while (buffer.events.length) await flush() + }), + ) + } + }, + } +} + +/** + * Node: events buffered in the process and written every few seconds (or when + * the buffer fills). A crash loses at most the unwritten buffer. + */ +export function bufferedUsageSink(ports: () => Ports, flushMs = 10_000, max = 5_000): UsageSink { + let buf: UsageEvent[] = [] + const flush = async () => { + if (buf.length === 0) return + const out = buf + buf = [] + try { + await writeUsage(ports(), out) + } catch (err) { + console.error('[usage] write failed; events kept for the next flush', err) + buf = [...out, ...buf] + } + } + setInterval(() => void flush(), flushMs).unref?.() + return { + record(events) { + buf.push(...events) + if (buf.length >= max) void flush() + }, + } +} + +registerJob('usage.rebuild', async (job, ports) => { + const day = String(job.day) + const next = await rebuildUsageStep(ports, day, Number(job.step ?? 0)) + if (next !== null) await ports.jobs.enqueue({ type: 'usage.rebuild', day, step: next }) +}) diff --git a/packages/server/src/cache.ts b/packages/server/src/cache.ts new file mode 100644 index 0000000..3bf9801 --- /dev/null +++ b/packages/server/src/cache.ts @@ -0,0 +1,53 @@ +import { type Cache, Lru } from '@underlay/protocol' + +/** Node: an in-process LRU (the isolate LRU in Objects sits in front of it). */ +export class MemoryCache implements Cache { + readonly #lru: Lru + constructor(budgetBytes = 256 * 1024 * 1024) { + this.#lru = new Lru(budgetBytes, (v) => v.byteLength) + } + async get(key: string) { + return this.#lru.get(key) ?? null + } + async put(key: string, value: Uint8Array) { + this.#lru.set(key, value) + } +} + +interface CfCacheStorage { + default: { + match(req: Request): Promise + put(req: Request, res: Response): Promise + } +} + +/** + * Workers: the per-colo Cache API. Keys are synthetic URLs; everything cached is + * immutable and keyed by content, so it's cached for a year. + */ +export class CfCache implements Cache { + constructor( + readonly caches: CfCacheStorage, + readonly namespace: string, + /** Run a cache write after the response (ctx.waitUntil), not on the request's path. */ + readonly defer?: (p: Promise) => void, + ) {} + #req(key: string) { + return new Request(`https://cache.underlay.internal/${this.namespace}/${key}`) + } + async get(key: string) { + const res = await this.caches.default.match(this.#req(key)) + return res ? new Uint8Array(await res.arrayBuffer()) : null + } + async put(key: string, value: Uint8Array, opts?: { ttlSeconds?: number }) { + const ttl = opts?.ttlSeconds ?? 31_536_000 + const write = this.caches.default.put( + this.#req(key), + new Response(value as Uint8Array, { + headers: { 'cache-control': `public, max-age=${ttl}, immutable` }, + }), + ) + if (this.defer) this.defer(write.catch((err: unknown) => console.error('[cache] put', err))) + else await write + } +} diff --git a/packages/server/src/cleanup/config.ts b/packages/server/src/cleanup/config.ts new file mode 100644 index 0000000..71416f2 --- /dev/null +++ b/packages/server/src/cleanup/config.ts @@ -0,0 +1,145 @@ +/** + * Storage cleanup settings, and the run rows every step reports into + * (planning: v2-storage-cleanup.md). + */ +import { eq } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import type { Db } from '../ports.js' + +const HOUR = 60 * 60 * 1000 +const DAY = 24 * HOUR + +export const cleanupConfig = { + /** A finished push session keeps its objects this long after its last activity. */ + sessionGraceMs: DAY, + /** A direct upload still pending after this is abandoned. */ + uploadGraceMs: DAY, + /** Internal objects with no row (a crash between write and insert) are orphans past this age. */ + orphanAgeMs: 2 * DAY, + /** A deleted collection's objects stay this long, so a mistaken delete can be recovered by hand. */ + tombstoneGraceMs: 7 * DAY, + /* + * Budgets per job. A Worker invocation makes at most 10,000 subrequests (R2 + * calls through the binding count) and 1,000 D1 queries. + */ + /** Sessions, uploads and reports one batch of step 1 looks at, at most. */ + internalBatch: 40, + /** Objects one batch of step 1 deletes, at most. */ + internalObjects: 2_000, + /** + * Tree nodes one mark job reads before it saves and hands over (about a node per + * 1,000 records). Checked before every read, so a job stops inside a collection, + * or inside one version's tree. + */ + markNodeBudget: 5_000, + /** Nodes a mark reads at once. */ + markConcurrency: 16, + /** Reads between a mark job's checkpoints (a shard, its place and its counts). */ + markCheckpoint: 500, + /** Collections one mark job takes (two queries each, against D1's 1,000). */ + markCollections: 300, + /** Hashes a mark may hold in memory; past it the run fails rather than run out (shard by prefix then). */ + maxMarked: 3_000_000, + /** Listing pages one sweep job reads, and objects it deletes, at most. */ + sweepPages: 20, + sweepDeletes: 2_000, + /** How long a deletion window stays open, and the objects one window deletes at most. */ + windowMs: 30_000, + windowObjects: 400, + deleteConcurrency: 16, + /** A push committing for less than this holds up a deletion window (it would fail otherwise). */ + committingHoldMs: 6 * HOUR, + /** How long a sweep waits for pushes to finish before it tries again. */ + waitSeconds: 120, + /** The automatic mark and sweep (when switched on), at most this often. */ + autoEveryMs: 7 * DAY, +} + +/** The instance setting that switches the weekly automatic mark and sweep on. */ +export const AUTO_SETTING = 'cleanup_auto' +/** + * The instance setting that pauses marks and sweeps: no run starts, and a sweep + * opens no deletion window. Set it while anything writes to the platform bucket + * outside the fence (the v1 migration tools, whose rows arrive later): + * wrangler d1 execute … --command "INSERT OR REPLACE INTO instance_settings (key, value, updated_at) VALUES ('cleanup_paused', 'true', 0)" + */ +export const PAUSE_SETTING = 'cleanup_paused' + +/** Whether marks and sweeps are paused (PAUSE_SETTING). */ +export async function cleanupPaused(db: Db): Promise { + const [row] = await db + .select({ value: schema.instanceSettings.value }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.key, PAUSE_SETTING)) + return row?.value === true +} + +export function emptyStats(): schema.CleanupStats { + return { + deleted: {}, + scanned: 0, + unknown: 0, + marked: 0, + versions: 0, + collections: 0, + rows: 0, + windows: 0, + problems: [], + } +} + +export function count(stats: schema.CleanupStats, kind: string, bytes: number, objects = 1) { + const c = (stats.deleted[kind] ??= { objects: 0, bytes: 0 }) + c.objects += objects + c.bytes += bytes +} + +/** Keys a sweep keeps as samples, per kind. */ +export const SAMPLES_PER_KIND = 25 + +/** Keep `key` as a sample of what a sweep deletes, unless the kind has enough. */ +export function sample(stats: schema.CleanupStats, kind: string, key: string) { + const list = ((stats.samples ??= {})[kind] ??= []) + if (list.length < SAMPLES_PER_KIND && !list.includes(key)) list.push(key) +} + +export function problem(stats: schema.CleanupStats, msg: string) { + if (stats.problems.length < 20) stats.problems.push(msg.slice(0, 300)) +} + +/** Add `b` into `a` (a run's jobs each report what they did). */ +export function addStats(a: schema.CleanupStats, b: schema.CleanupStats): schema.CleanupStats { + for (const [k, c] of Object.entries(b.deleted)) count(a, k, c.bytes, c.objects) + a.scanned += b.scanned + a.unknown += b.unknown + a.marked += b.marked + a.versions += b.versions + a.collections += b.collections + a.rows += b.rows + a.windows += b.windows + for (const p of b.problems) problem(a, p) + for (const [k, list] of Object.entries(b.samples ?? {})) for (const key of list) sample(a, k, key) + return a +} + +export type RunRow = typeof schema.cleanupRuns.$inferSelect + +export async function getRun(db: Db, id: string): Promise { + const [r] = await db.select().from(schema.cleanupRuns).where(eq(schema.cleanupRuns.id, id)) + return r ?? null +} + +export async function updateRun( + db: Db, + id: string, + set: Partial, +): Promise { + await db + .update(schema.cleanupRuns) + .set({ ...set, updatedAt: new Date() }) + .where(eq(schema.cleanupRuns.id, id)) +} + +/** Where a run keeps its working objects (mark shards), in the internal area. */ +export const runDir = (runId: string) => `cleanup/${runId}` diff --git a/packages/server/src/cleanup/fence.ts b/packages/server/src/cleanup/fence.ts new file mode 100644 index 0000000..c6a5385 --- /dev/null +++ b/packages/server/src/cleanup/fence.ts @@ -0,0 +1,184 @@ +/** + * The write fence between writers and the storage sweep (planning: + * v2-storage-cleanup.md, "The fence"). + * + * Writers skip keys that already exist (`ifAbsent`), so a push can reuse a node + * or file a deleted collection left behind. If the sweep deleted that object + * while the push was in flight, the push would publish a version with a hole. + * + * - A write phase reads the epoch first (`writeFence`), waiting out an open + * deletion window. + * - The statement that makes its objects reachable (a publish, a files row, a + * possession, a tree root on a row) carries `fenceHolds(epoch)`: it matches + * only if the epoch is unchanged and no window is open. + * - The sweep deletes only inside a window. Opening one bumps the epoch, so a + * write phase that began before it can't publish; closing bumps it again, so + * one that began during it can't either. The writer then redoes its writes, + * which puts back whatever was deleted. + * - A window has a deadline. A crashed sweep can't hold writers past it (plus + * `slackMs`), and the sweep stops issuing deletes `marginMs` before it, so a + * delete would have to hang for minutes to land after writers resume. + * - Every time comparison uses the database's clock, so writers and the sweep + * agree on whether a window is open whatever their own clocks say. + */ +import { and, eq, isNull, or, type SQL, sql } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import type { Db } from '../ports.js' + +export const fenceConfig = { + /** How long a writer waits for a window to close before giving up (503). */ + waitMs: 180_000, + pollMs: 500, + /** A window counts as open this long past its deadline, for deletes still in flight. */ + slackMs: 120_000, + /** The sweep issues no delete closer than this to its deadline. */ + marginMs: 15_000, +} + +const FENCE_ID = 1 + +/** A deletion window stayed open longer than a writer waits: retry later. */ +export class StorageBusyError extends Error { + constructor() { + super('Storage cleanup is deleting objects; try again in a minute') + this.name = 'StorageBusyError' + } +} + +/** A deletion window opened during this write phase: its writes must be redone. */ +export class FenceError extends Error { + constructor() { + super('Storage cleanup ran during this write; it has to be redone') + this.name = 'FenceError' + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +/** The database's time, in ms. */ +const dbNow = sql`(unixepoch('subsec') * 1000)` + +async function readFence(db: Db) { + const [row] = await db + .select({ + epoch: schema.storageFence.epoch, + windowUntil: schema.storageFence.windowUntil, + windowRunId: schema.storageFence.windowRunId, + now: dbNow, + }) + .from(schema.storageFence) + .where(eq(schema.storageFence.id, FENCE_ID)) + .limit(1) + if (!row) throw new Error('storage_fence has no row (migration 0015)') + return { ...row, now: Number(row.now) } +} + +/** The epoch to fence a write phase with. Waits while a deletion window is open. */ +export async function writeFence(db: Db): Promise { + const deadline = Date.now() + fenceConfig.waitMs + for (;;) { + const row = await readFence(db) + const until = row.windowUntil?.getTime() + if (until === undefined || until < row.now - fenceConfig.slackMs) return row.epoch + if (Date.now() > deadline) throw new StorageBusyError() + await sleep(fenceConfig.pollMs) + } +} + +/** + * SQL that holds while `epoch` is current and no window is open: the condition + * on a statement that makes a write phase's objects reachable. + */ +export function fenceHolds(epoch: number): SQL { + return sql`EXISTS (SELECT 1 FROM ${schema.storageFence} WHERE ${schema.storageFence.id} = ${FENCE_ID} AND ${schema.storageFence.epoch} = ${epoch} AND (${schema.storageFence.windowUntil} IS NULL OR ${schema.storageFence.windowUntil} < ${dbNow} - ${fenceConfig.slackMs}))` +} + +/** After a fenced statement matched nothing: was it the fence (rather than its own condition)? */ +export async function fenceMoved(db: Db, epoch: number): Promise { + const [row] = await db + .select({ ok: sql`1` }) + .from(schema.storageFence) + .where(sql`${fenceHolds(epoch)}`) + .limit(1) + return !row +} + +/** + * Run a write phase under the fence, redoing it (a few times) when a window + * opened during it. `phase` must be safe to repeat: writes are content-addressed + * and its reachability statements carry `fenceHolds(epoch)`. + */ +export async function fenced(db: Db, phase: (epoch: number) => Promise): Promise { + for (let attempt = 1; ; attempt++) { + const epoch = await writeFence(db) + try { + return await phase(epoch) + } catch (err) { + if (!(err instanceof FenceError) || attempt >= 3) throw err + } + } +} + +// --- The sweep's side ------------------------------------------------------------------- + +export interface Window { + runId: string + /** The epoch while the window is open. */ + epoch: number + until: number +} + +/** Open a deletion window for `ms`, unless one is already open. */ +export async function openWindow(db: Db, runId: string, ms: number): Promise { + const [row] = await db + .update(schema.storageFence) + .set({ + epoch: sql`${schema.storageFence.epoch} + 1`, + windowUntil: sql`${dbNow} + ${ms}`, + windowRunId: runId, + }) + .where( + and( + eq(schema.storageFence.id, FENCE_ID), + or( + isNull(schema.storageFence.windowUntil), + sql`${schema.storageFence.windowUntil} < ${dbNow} - ${fenceConfig.slackMs}`, + ), + ), + ) + .returning({ epoch: schema.storageFence.epoch, until: schema.storageFence.windowUntil }) + return row ? { runId, epoch: row.epoch, until: row.until!.getTime() } : null +} + +/** Whether a delete may still be issued in this window (it's ours, and not near its deadline). */ +export async function windowStillOpen(db: Db, w: Window): Promise { + const row = await readFence(db) + if (row.now > w.until - fenceConfig.marginMs) return false + return row.epoch === w.epoch && row.windowRunId === w.runId +} + +/** Close the window: writers resume, and any that read the epoch during it must redo. */ +export async function closeWindow(db: Db, w: Window): Promise { + await db + .update(schema.storageFence) + .set({ epoch: sql`${schema.storageFence.epoch} + 1`, windowUntil: null, windowRunId: null }) + .where( + and( + eq(schema.storageFence.id, FENCE_ID), + // Only this window: a later one (even of the same run) closes itself. + eq(schema.storageFence.windowRunId, w.runId), + eq(schema.storageFence.epoch, w.epoch), + ), + ) +} + +/** The fence row, for the admin page. */ +export async function fenceState(db: Db) { + const [row] = await db + .select() + .from(schema.storageFence) + .where(eq(schema.storageFence.id, FENCE_ID)) + .limit(1) + return row ?? null +} diff --git a/packages/server/src/cleanup/internal.ts b/packages/server/src/cleanup/internal.ts new file mode 100644 index 0000000..067749f --- /dev/null +++ b/packages/server/src/cleanup/internal.ts @@ -0,0 +1,195 @@ +/** + * Cleanup step 1: platform-internal objects, by their rows (planning: + * v2-storage-cleanup.md). Runs a bounded batch on every cron tick; a manual run + * repeats batches until nothing is left. + * + * - push sessions committed, failed or expired, idle past the grace period: + * everything under sessions//, and their run and commit-unit rows; + * - reference-log scratch (sessions/refs-/) once the version is + * indexed or gone; + * - direct uploads still pending past the grace period: the multipart upload + * aborted, the staging object deleted, the row failed; + * - fsck reports of collections that no longer exist. + * + * Nothing here is reachable from a version, so it needs no fence. + */ +import type { Store } from '@underlay/protocol' +import { and, eq, inArray, isNull, lt, sql } from 'drizzle-orm' + +import { chunks } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import type { Ports } from '../ports.js' +import { cleanupConfig, count, emptyStats, problem } from './config.js' + +const TERMINAL: schema.SessionStatus[] = ['committed', 'failed', 'expired'] + +/** + * Delete everything under a prefix, up to `budget` objects; returns objects and + * bytes (counted, not deleted, when dry) and whether it got to the end. + */ +export async function deletePrefix( + store: Store, + prefix: string, + dryRun: boolean, + budget = Infinity, +): Promise<{ objects: number; bytes: number; complete: boolean }> { + let objects = 0 + let bytes = 0 + let cursor: string | undefined + for (;;) { + // Deleting moves the listing's start, so a real run lists from the top each time. + if (objects >= budget) return { objects, bytes, complete: false } + const page = await store.list(prefix, dryRun ? cursor : undefined) + const keys = page.keys.slice(0, Math.max(0, budget - objects)) + objects += keys.length + keys.forEach((_, i) => (bytes += page.info?.[i]?.size ?? 0)) + if (!dryRun) await Promise.all(keys.map((k) => store.delete(k))) + if (keys.length < page.keys.length) return { objects, bytes, complete: false } + if (!page.cursor) break + cursor = page.cursor + } + return { objects, bytes, complete: true } +} + +/** One batch of step 1. `done` is false while more is left than a batch takes. */ +export async function cleanInternal( + ports: Ports, + opts: { dryRun?: boolean; batch?: number } = {}, +): Promise<{ stats: schema.CleanupStats; done: boolean }> { + const { db } = ports + const dryRun = opts.dryRun ?? false + const batch = opts.batch ?? cleanupConfig.internalBatch + const internal = ports.stores.internal + const stats = emptyStats() + const now = Date.now() + let more = false + let budget = cleanupConfig.internalObjects + + // Push sessions. + const sessions = await db + .select({ id: schema.pushSessions.id }) + .from(schema.pushSessions) + .where( + and( + isNull(schema.pushSessions.cleanedAt), + inArray(schema.pushSessions.status, TERMINAL), + lt(schema.pushSessions.expiresAt, new Date(now - cleanupConfig.sessionGraceMs)), + ), + ) + .limit(batch + 1) + if (sessions.length > batch) more = true + for (const s of sessions.slice(0, batch)) { + if (budget <= 0) { + more = true + break + } + stats.scanned++ + const gone = await deletePrefix(internal, `sessions/${s.id}/`, dryRun, budget) + budget -= gone.objects + count(stats, 'sessions', gone.bytes, gone.objects) + // A session bigger than what's left of the budget finishes next time. + if (!gone.complete) more = true + if (dryRun || !gone.complete) continue + const [runs, units] = await Promise.all([ + db + .delete(schema.pushRuns) + .where(eq(schema.pushRuns.sessionId, s.id)) + .returning({ seq: schema.pushRuns.seq }), + db + .delete(schema.commitUnits) + .where(eq(schema.commitUnits.sessionId, s.id)) + .returning({ id: schema.commitUnits.id }), + ]) + stats.rows += runs.length + units.length + await db + .update(schema.pushSessions) + .set({ cleanedAt: new Date() }) + .where(eq(schema.pushSessions.id, s.id)) + } + + // Reference-log scratch: one directory per version being indexed. + const scratch = await internal.list('sessions/refs-') + const versionIds = [ + ...new Set(scratch.keys.map((k) => k.slice('sessions/refs-'.length).split('/')[0]!)), + ] + if (scratch.cursor) more = true + for (const part of chunks(versionIds)) { + const rows = await db + .select({ id: schema.versions.id, indexed: schema.versions.refsIndexed }) + .from(schema.versions) + .where(inArray(schema.versions.id, part)) + const indexing = new Set(rows.filter((r) => !r.indexed).map((r) => r.id)) + for (const v of part) { + if (indexing.has(v) || budget <= 0) continue + stats.scanned++ + const gone = await deletePrefix(internal, `sessions/refs-${v}/`, dryRun, budget) + budget -= gone.objects + if (!gone.complete) more = true + count(stats, 'refs scratch', gone.bytes, gone.objects) + } + } + + // Direct uploads nobody finished. + const abandoned = await db + .select() + .from(schema.fileUploads) + .where( + and( + eq(schema.fileUploads.status, 'pending'), + lt(schema.fileUploads.createdAt, new Date(now - cleanupConfig.uploadGraceMs)), + ), + ) + .limit(batch + 1) + if (abandoned.length > batch) more = true + const blobs = ports.stores.fileBytes + for (const u of abandoned.slice(0, batch)) { + stats.scanned++ + const head = await blobs.head(u.storageKey).catch(() => null) + count(stats, 'uploads', head?.size ?? 0, head ? 1 : 0) + if (dryRun) continue + if (u.multipartUploadId) { + await blobs.presigner + .abortMultipart(u.storageKey, u.multipartUploadId) + .catch((err) => problem(stats, `abort upload ${u.id}: ${(err as Error).message}`)) + } + await blobs.delete(u.storageKey) + await db + .update(schema.fileUploads) + .set({ status: 'failed', error: 'Abandoned: the upload was never completed' }) + .where(and(eq(schema.fileUploads.id, u.id), eq(schema.fileUploads.status, 'pending'))) + } + const [stuck] = await db + .select({ n: sql`count(*)` }) + .from(schema.fileUploads) + .where( + and( + eq(schema.fileUploads.status, 'verifying'), + lt(schema.fileUploads.createdAt, new Date(now - cleanupConfig.uploadGraceMs)), + ), + ) + if (stuck?.n) problem(stats, `${stuck.n} uploads have been verifying for over a day`) + + // fsck reports of collections that are gone. + const reports = await internal.list('fsck/') + if (reports.cursor) more = true + const ids = reports.keys.map((k) => k.slice('fsck/'.length).replace(/\.json$/, '')) + for (const part of chunks(ids)) { + const live = new Set( + ( + await db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where(inArray(schema.collections.id, part)) + ).map((r) => r.id), + ) + for (const id of part) { + if (live.has(id)) continue + stats.scanned++ + const i = ids.indexOf(id) + count(stats, 'fsck reports', reports.info?.[i]?.size ?? 0) + if (!dryRun) await internal.delete(`fsck/${id}.json`) + } + } + + return { stats, done: !more } +} diff --git a/packages/server/src/cleanup/mark.ts b/packages/server/src/cleanup/mark.ts new file mode 100644 index 0000000..8ced1e9 --- /dev/null +++ b/packages/server/src/cleanup/mark.ts @@ -0,0 +1,245 @@ +/** + * Cleanup step 2: mark (planning: v2-storage-cleanup.md). A run of jobs, each + * reading up to `markNodeBudget` tree nodes, then saving what it added as a + * shard and handing over. The budget holds inside a collection and inside a + * version: a job stops mid-tree, and the next walks the tree again from its + * root, skipping what's marked. Every `markCheckpoint` reads a job saves a + * shard and writes its place and counts, so the page shows progress and a job + * that dies loses little. Phases, in order: + * + * collections every version of every live collection on the platform + * location, with its file reference count trees, and the + * collection's cumulative public files tree + * tombstones collections deleted within the grace period, from their logs + * possessions files a live (or recently deleted) collection proved it holds + * + * Only the platform location is marked and swept; customer locations never are. + */ +import { openRepo, type Repo } from '@underlay/protocol' +import { and, asc, eq, gt, sql } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import type { Ports } from '../ports.js' +import { cleanupConfig, emptyStats, problem } from './config.js' +import { Marker, MarkSet } from './marks.js' + +export interface MarkState { + phase: 'collections' | 'tombstones' | 'possessions' | 'done' + /** The last id handled in the phase. */ + after: string | null + /** + * The collection (or tombstone) after `after` that a job stopped inside, and + * the last version of it marked in full (`seq`; a log entry for a tombstone). + */ + current?: { id: string; seq: number } | null +} + +export const firstMarkState = (): MarkState => ({ phase: 'collections', after: null }) + +/** + * The platform repository without the shared cache: a cached read costs a cache + * lookup and a cache write besides the bucket read, three subrequests a node. + */ +export async function markRepo(ports: Ports): Promise { + const platform = await ports.stores.forLocation(schema.PLATFORM_LOCATION_ID) + return openRepo(platform.blobs, { trusted: true }) +} + +/** Collections deleted after this still keep their objects. */ +export const graceCutoff = () => new Date(Date.now() - cleanupConfig.tombstoneGraceMs) + +/** + * A live collection's versions after `afterSeq`, with their refs trees, then its + * cumulative public files tree. Returns the last version it marked in full and + * whether it finished. + */ +export async function markCollection( + ports: Ports, + marker: Marker, + collection: { id: string; publicFilesRoot: string | null }, + afterSeq = 0, + onVersion: (seq: number) => void = () => {}, +): Promise<{ seq: number; complete: boolean }> { + const versions = await ports.db + .select({ + seq: schema.versions.seq, + hash: schema.versions.hash, + publicRefsRoot: schema.versions.publicRefsRoot, + privateRefsRoot: schema.versions.privateRefsRoot, + }) + .from(schema.versions) + .where(and(eq(schema.versions.collectionId, collection.id), gt(schema.versions.seq, afterSeq))) + .orderBy(asc(schema.versions.seq)) + let seq = afterSeq + for (const v of versions) { + const done = + (await marker.version(v.hash)) && + (await marker.counts(v.publicRefsRoot)) && + (await marker.counts(v.privateRefsRoot)) + if (!done) return { seq, complete: false } + seq = v.seq + onVersion(seq) + } + return { seq, complete: await marker.files(collection.publicFilesRoot) } +} + +/** + * Whether a collection's objects may be in the platform location (the only one + * swept): its primary is there, or (never expected) it has no primary at all. + */ +async function onPlatform(ports: Ports, collectionId: string): Promise { + const [p] = await ports.db + .select({ locationId: schema.placements.locationId }) + .from(schema.placements) + .where( + and(eq(schema.placements.collectionId, collectionId), eq(schema.placements.role, 'primary')), + ) + .limit(1) + return !p || p.locationId === schema.PLATFORM_LOCATION_ID +} + +/** Files proved by a collection that is live or deleted within the grace period. */ +export const possessionHeld = (cutoff: Date) => + sql`(EXISTS (SELECT 1 FROM ${schema.collections} WHERE ${schema.collections.id} = ${schema.fileUploads.collectionId}) OR EXISTS (SELECT 1 FROM ${schema.collectionTombstones} WHERE ${schema.collectionTombstones.collectionId} = ${schema.fileUploads.collectionId} AND ${schema.collectionTombstones.deletedAt} > ${cutoff.getTime()}))` + +/** What a mark job saves at a checkpoint: its place, and what it counted since it began. */ +export type MarkCheckpoint = (state: MarkState, stats: schema.CleanupStats) => Promise + +/** + * One mark job: continue from `state`, adding to `marks` (loaded from the + * run's earlier shards), until the phases are done or the budget is spent. + * `checkpoint` is called every `markCheckpoint` reads with a resumable place. + */ +export async function markStep( + ports: Ports, + marks: MarkSet, + state: MarkState, + checkpoint?: MarkCheckpoint, +): Promise<{ state: MarkState; stats: schema.CleanupStats }> { + const { db } = ports + const stats = emptyStats() + const s: MarkState = { current: null, ...state } + const budget = marks.reads + cleanupConfig.markNodeBudget + let lastCheckpoint = marks.reads + let saving: Promise | null = null + const marker = new Marker(await markRepo(ports), marks, { + budget, + concurrency: cleanupConfig.markConcurrency, + onRead: async () => { + if (!checkpoint || saving || marks.reads - lastCheckpoint < cleanupConfig.markCheckpoint) + return + lastCheckpoint = marks.reads + // One at a time; the walk's other branches carry on meanwhile. + saving = checkpoint(structuredClone(s), structuredClone(stats)).finally(() => { + saving = null + }) + await saving + }, + }) + let handled = 0 + const spent = () => marker.spent || handled >= cleanupConfig.markCollections + const tooMany = () => { + if (marks.size > cleanupConfig.maxMarked) + throw new Error( + `The mark holds over ${cleanupConfig.maxMarked} hashes, more than one pass can; it needs sharding by prefix`, + ) + } + + while (s.phase === 'collections' && !spent()) { + const rows = await db + .select({ id: schema.collections.id, publicFilesRoot: schema.collections.publicFilesRoot }) + .from(schema.collections) + .where(s.after ? gt(schema.collections.id, s.after) : undefined) + .orderBy(asc(schema.collections.id)) + .limit(50) + if (rows.length === 0) Object.assign(s, { phase: 'tombstones', after: null, current: null }) + for (const c of rows) { + if (await onPlatform(ports, c.id)) { + const from = s.current?.id === c.id ? s.current.seq : 0 + const r = await markCollection(ports, marker, c, from, (seq) => { + s.current = { id: c.id, seq } + stats.versions++ + }) + tooMany() + if (!r.complete) { + s.current = { id: c.id, seq: r.seq } + break + } + stats.collections++ + } + Object.assign(s, { after: c.id, current: null }) + handled++ + if (spent()) break + } + } + + while (s.phase === 'tombstones' && !spent()) { + const rows = await db + .select({ + id: schema.collectionTombstones.collectionId, + slug: schema.collectionTombstones.slug, + versions: schema.collectionTombstones.versions, + }) + .from(schema.collectionTombstones) + .where( + and( + gt(schema.collectionTombstones.deletedAt, graceCutoff()), + s.after ? gt(schema.collectionTombstones.collectionId, s.after) : undefined, + ), + ) + .orderBy(asc(schema.collectionTombstones.collectionId)) + .limit(50) + if (rows.length === 0) Object.assign(s, { phase: 'possessions', after: null, current: null }) + for (const t of rows) { + // A row means live, tombstone or not. + const [live] = await db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where(eq(schema.collections.id, t.id)) + if (!live) { + const from = s.current?.id === t.id ? s.current.seq : 0 + const r = await marker.fromLog(t.id, from) + stats.versions += r.seq - from + tooMany() + if (!r.complete) { + s.current = { id: t.id, seq: r.seq } + break + } + stats.collections++ + // Versions its log lacks (an append that failed) lose their grace period. + const why = + r.problem ?? + (r.length < t.versions ? `its log has ${r.length} of ${t.versions} versions` : null) + if (why) + problem(stats, `Deleted collection ${t.slug} (${t.id}) is only partly kept: ${why}`) + } + Object.assign(s, { after: t.id, current: null }) + handled++ + if (spent()) break + } + } + + while (s.phase === 'possessions' && !spent()) { + const rows = await db + .select({ id: schema.fileUploads.id, hash: schema.fileUploads.hash }) + .from(schema.fileUploads) + .where( + and( + eq(schema.fileUploads.status, 'verified'), + s.after ? gt(schema.fileUploads.id, s.after) : undefined, + possessionHeld(graceCutoff()), + ), + ) + .orderBy(asc(schema.fileUploads.id)) + .limit(500) + if (rows.length === 0) Object.assign(s, { phase: 'done', after: null, current: null }) + for (const r of rows) marks.add('f', r.hash) + if (rows.length) s.after = rows[rows.length - 1]!.id + tooMany() + // Rows aren't node reads, but bound the queries one job sends. + marks.reads += rows.length / 50 + } + + await saving + return { state: s, stats } +} diff --git a/packages/server/src/cleanup/marks.ts b/packages/server/src/cleanup/marks.ts new file mode 100644 index 0000000..4c28577 --- /dev/null +++ b/packages/server/src/cleanup/marks.ts @@ -0,0 +1,254 @@ +/** + * The mark: every hash a live collection still reaches (planning: + * v2-storage-cleanup.md, step 2). + * + * A version reaches its root (`roots/`), its private set object + * (`private/`), every node of its record and file trees + * (`nodes/`), each leaf's body (`bodies/`, so the leaf hash + * covers it), its out-of-line records (`records/`: entries over + * OUT_OF_LINE_BYTES, the only threshold any writer uses) and its files + * (`files/`). Rows add the file reference count trees and the cumulative + * public files tree. Schemas are never swept, so they aren't marked. + * + * Only tree nodes are read. The set is exact, so a node already in it is + * skipped with its whole subtree: shared history costs one read per distinct + * node. A read that fails fails the mark: a sweep never runs on a partial one. + * + * Entries are kept per kind (`n:` a walked node, which covers its body, `v:` a + * walked version root, `p:` a private set object, `r:` an out-of-line record, + * `f:` a file). One flat set would let a file whose bytes happen to be a node's + * JSON (hash for hash) pass for that node, and the walk would skip the subtree. + */ +import { + countTree, + type FileEntry, + fileTree, + gunzipText, + gzip, + keys, + OUT_OF_LINE_BYTES, + readHead, + readLogEntry, + type RecordEntry, + recordTree, + type Repo, + type SetObject, + type Store, + type TreeSpec, + versionDigest, +} from '@underlay/protocol' + +import { runDir } from './config.js' + +/** n: node, v: version root, p: private set object, r: out-of-line record, f: file. */ +export type MarkKind = 'n' | 'v' | 'p' | 'r' | 'f' + +export class MarkSet { + readonly hashes = new Set() + /** Added since the last save. */ + #fresh: string[] = [] + /** Tree nodes read, for job budgets. */ + reads = 0 + + has(kind: MarkKind, h: string): boolean { + return this.hashes.has(`${kind}:${h}`) + } + + add(kind: MarkKind, h: string): void { + const token = `${kind}:${h}` + if (this.hashes.has(token)) return + this.hashes.add(token) + this.#fresh.push(token) + } + + get size(): number { + return this.hashes.size + } + + /** Load every shard a run saved under `runDir(runId)` with this name prefix. */ + async load(store: Store, runId: string, name = ''): Promise { + const prefix = `${runDir(runId)}/${name}` + let cursor: string | undefined + do { + const page = await store.list(prefix, cursor) + for (const key of page.keys) { + if (!key.endsWith('.txt.gz')) continue + const obj = await store.get(key) + if (!obj) throw new Error(`Mark shard ${key} vanished`) + for (const h of (await gunzipText(await obj.bytes())).split('\n')) { + if (h) this.hashes.add(h) + } + } + cursor = page.cursor + } while (cursor) + } + + /** Save what was added since the last save as one shard. Returns how many. */ + async save(store: Store, runId: string, name: string): Promise { + // Taken before the write: a walk may still add while it's in flight. + const fresh = this.#fresh + if (fresh.length === 0) return 0 + this.#fresh = [] + const key = `${runDir(runId)}/${name}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}.txt.gz` + try { + await store.put(key, await gzip(fresh.join('\n')), { contentType: 'application/gzip' }) + } catch (err) { + this.#fresh = fresh.concat(this.#fresh) + throw err + } + return fresh.length + } +} + +export interface MarkerOptions { + /** Stop reading once `marks.reads` reaches this (default: no limit). */ + budget?: number + /** Reads in flight at once. */ + concurrency?: number + /** Called after each read (a job writes its progress from here). */ + onRead?: () => Promise | void +} + +/** + * Walks a repository's trees into a MarkSet, reading up to `concurrency` nodes + * at a time. With a budget it stops once the budget is spent, and every walk + * says whether it finished: false means part of it is still to do, and marks + * hold only what is complete (a node once its subtree is, a version once its + * sets are), so walking it again from the top skips what's done. + */ +export class Marker { + readonly budget: number + readonly concurrency: number + readonly onRead: (() => Promise | void) | undefined + #active = 0 + #waiting: (() => void)[] = [] + readonly #startSize: number + + constructor( + readonly repo: Repo, + readonly marks: MarkSet, + opts: MarkerOptions = {}, + ) { + this.budget = opts.budget ?? Infinity + this.concurrency = Math.max(1, opts.concurrency ?? 16) + this.onRead = opts.onRead + this.#startSize = marks.size + } + + /** + * Whether the budget is spent. Not before this marker has added a mark: + * walking back down to where the last one stopped costs reads, and every + * marker has to get further than the one before it. + */ + get spent(): boolean { + return this.marks.reads >= this.budget && this.marks.size > this.#startSize + } + + /** One read, counted, inside the concurrency limit. */ + async #read(f: () => Promise): Promise { + while (this.#active >= this.concurrency) await new Promise((r) => this.#waiting.push(r)) + this.#active++ + try { + return await f() + } finally { + this.#active-- + this.marks.reads++ + this.#waiting.shift()?.() + } + } + + /** A tree and what its leaves point to. Marks a node only once its subtree is done. */ + async tree( + spec: TreeSpec, + root: string | null, + leaf?: (entries: E[]) => void, + ): Promise { + if (!root || this.marks.has('n', root)) return true + if (this.spent) return false + const node = await this.#read(() => this.repo.decoded(spec, root)) + await this.onRead?.() + if (node.kind === 'leaf') leaf?.(node.entries) + else { + // A slice of children at a time keeps the walk close to depth first. + for (let i = 0; i < node.children.length; i += this.concurrency) { + const part = node.children.slice(i, i + this.concurrency) + const done = await Promise.all(part.map((c) => this.tree(spec, c.hash, leaf))) + if (done.includes(false)) return false + } + } + this.marks.add('n', root) + return true + } + + records(root: string | null) { + return this.tree(recordTree, root, (entries) => { + for (const e of entries) if (e.size > OUT_OF_LINE_BYTES) this.marks.add('r', e.hash) + }) + } + + files(root: string | null) { + return this.tree(fileTree, root, (entries) => { + for (const e of entries) this.marks.add('f', e.key) + }) + } + + counts(root: string | null) { + return this.tree(countTree, root) + } + + async set(s: SetObject): Promise { + for (const t of Object.values(s.types)) if (!(await this.records(t.root))) return false + return this.files(s.files.root) + } + + /** A version: its sets, then its root (so a marked root means a fully marked version). */ + async version(hash: string): Promise { + const digest = versionDigest(hash) + if (this.marks.has('v', digest)) return true + if (this.spent) return false + const root = await this.#read(() => this.repo.root(hash)) + if (!(await this.set(root.public))) return false + if (root.private) { + // A version copied with its public sets only (a public mirror read back) + // keeps the commitment but not the object: nothing of it is here to keep. + if (await this.repo.blobs.head(keys.privateSet(root.private))) { + if (!(await this.set(await this.#read(() => this.repo.privateSet(root.private!))))) { + return false + } + } + this.marks.add('p', root.private) + } + this.marks.add('v', digest) + return true + } + + /** + * A deleted collection's versions, from its signed log (the rows went with it), + * starting after log entry `after`. Returns the last entry it finished, the + * log's length, and what it couldn't: a broken log only costs this collection + * its grace period, it doesn't stop every mark for a week. + */ + async fromLog( + collectionId: string, + after = 0, + ): Promise<{ seq: number; length: number; complete: boolean; problem: string | null }> { + let seq = after + let length = 0 + try { + const head = await readHead(this.repo, collectionId) + length = head?.seq ?? 0 + while (seq < length) { + if (this.spent) return { seq, length, complete: false, problem: null } + const entry = await this.#read(() => readLogEntry(this.repo, collectionId, seq + 1)) + if (!entry) + return { seq, length, complete: true, problem: `log entry ${seq + 1} is missing` } + if (!(await this.version(entry.versionHash))) + return { seq, length, complete: false, problem: null } + seq++ + } + return { seq, length, complete: true, problem: null } + } catch (err) { + return { seq, length, complete: true, problem: (err as Error).message } + } + } +} diff --git a/packages/server/src/cleanup/runs.ts b/packages/server/src/cleanup/runs.ts new file mode 100644 index 0000000..4ce2bd4 --- /dev/null +++ b/packages/server/src/cleanup/runs.ts @@ -0,0 +1,378 @@ +/** + * Storage cleanup runs (planning: v2-storage-cleanup.md): starting them, the + * jobs that carry them, and what the cron does each tick. + * + * cleanup.internal step 1 in batches (a manual run; the cron runs one batch a tick) + * cleanup.mark step 2, a job per node budget + * cleanup.sweep step 3, a job per page budget; waits while pushes commit + * + * Each job claims the run with a lease and runs only if it carries the run's + * current `seq`, so a duplicate or late delivery never forks a run in two. A + * job that dies leaves the lease to expire, and the cron requeues the run. A + * long job (a mark) writes checkpoints as it goes: its place, its counts so far + * and a fresh lease, so the page shows progress and a requeued job resumes there. + */ +import { and, desc, eq, gt, inArray, isNull, lt, or, sql } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import type { Ports } from '../ports.js' +import { + addStats, + AUTO_SETTING, + cleanupPaused, + cleanupConfig, + emptyStats, + getRun, + type RunRow, + runDir, + updateRun, +} from './config.js' +import { cleanInternal, deletePrefix } from './internal.js' +import { firstMarkState, markStep, type MarkState } from './mark.js' +import { MarkSet } from './marks.js' +import { sweepStep, type SweepState } from './sweep.js' + +const LEASE_MS = 15 * 60 * 1000 +const ACTIVE: schema.CleanupStatus[] = ['queued', 'running', 'waiting'] +/** A sweep uses a mark at most this old (re-marks grow with its age). */ +const MARK_VALID_MS = 3 * 24 * 60 * 60 * 1000 +/** A sweep waiting on pushes gives up after this many tries. */ +const MAX_WAITS = 60 +/** Working objects of runs finished this long ago are deleted. */ +const KEEP_WORK_MS = 14 * 24 * 60 * 60 * 1000 + +export class CleanupRefused extends Error { + constructor(message: string) { + super(message) + this.name = 'CleanupRefused' + } +} + +export interface StartOptions { + trigger: 'manual' | 'schedule' + dryRun?: boolean + requestedBy?: string | null + /** Mark: start a sweep on it when it's done. */ + thenSweep?: boolean + /** Sweep: the mark to use (default: the latest done one). */ + markRunId?: string +} + +/** Start a run of a step. Refused while one of the same kind is in progress. */ +export async function startRun( + ports: Ports, + step: schema.CleanupStep, + opts: StartOptions, +): Promise { + const { db } = ports + const kinds: schema.CleanupStep[] = step === 'internal' ? ['internal'] : ['mark', 'sweep'] + const [busy] = await db + .select({ id: schema.cleanupRuns.id, step: schema.cleanupRuns.step }) + .from(schema.cleanupRuns) + .where(and(inArray(schema.cleanupRuns.step, kinds), inArray(schema.cleanupRuns.status, ACTIVE))) + .limit(1) + if (busy) throw new CleanupRefused(`A ${busy.step} run is already in progress`) + if (step !== 'internal' && (await cleanupPaused(db))) + throw new CleanupRefused('Marks and sweeps are paused (cleanup_paused)') + + let state: Record = {} + let markRunId: string | null = null + if (step === 'mark') state = { ...firstMarkState(), thenSweep: opts.thenSweep ?? false } + if (step === 'sweep') { + const [mark] = await db + .select() + .from(schema.cleanupRuns) + .where( + and( + eq(schema.cleanupRuns.step, 'mark'), + eq(schema.cleanupRuns.status, 'done'), + opts.markRunId ? eq(schema.cleanupRuns.id, opts.markRunId) : undefined, + ), + ) + .orderBy(desc(schema.cleanupRuns.createdAt)) + .limit(1) + if (!mark?.startedAt) + throw new CleanupRefused('No finished mark to sweep with: run a mark first') + if (Date.now() - mark.startedAt.getTime() > MARK_VALID_MS) + throw new CleanupRefused('The last mark is more than 3 days old: run a new one') + markRunId = mark.id + state = { area: 0, cursor: null, markStartedAt: mark.startedAt.getTime() } satisfies SweepState + } + const [run] = await db + .insert(schema.cleanupRuns) + .values({ + step, + trigger: opts.trigger, + dryRun: opts.dryRun ?? false, + requestedBy: opts.requestedBy ?? null, + markRunId, + state, + stats: emptyStats(), + seq: 1, + updatedAt: new Date(), + }) + .returning() + await ports.jobs.enqueue({ type: `cleanup.${step}`, runId: run!.id, seq: 1 }) + return run! +} + +/** Claim a run for one job: its seq, and no live lease. Null: not this job's turn. */ +async function claim(ports: Ports, runId: string, seq: number): Promise { + const now = Date.now() + const [run] = await ports.db + .update(schema.cleanupRuns) + .set({ lease: new Date(now) }) + .where( + and( + eq(schema.cleanupRuns.id, runId), + eq(schema.cleanupRuns.seq, seq), + inArray(schema.cleanupRuns.status, ACTIVE), + or( + isNull(schema.cleanupRuns.lease), + lt(schema.cleanupRuns.lease, new Date(now - LEASE_MS)), + ), + ), + ) + .returning() + if (run) return run + const current = await getRun(ports.db, runId) + return current?.seq === seq && ACTIVE.includes(current.status) ? 'held' : null +} + +/** Save a job's place and its counts so far (added to the run's), and renew its lease. */ +type Checkpoint = ( + state: Record, + stats: schema.CleanupStats, + marked?: number, +) => Promise + +/** Run one job of a run, then hand over to the next (or finish). */ +async function runJob( + ports: Ports, + job: { runId: string; seq: number; type: string }, + work: ( + run: RunRow, + checkpoint: Checkpoint, + ) => Promise<{ + state: Record + stats: schema.CleanupStats + outcome: 'more' | 'done' | 'wait' + marked?: number + }>, +): Promise { + const run = await claim(ports, job.runId, job.seq) + if (run === 'held') { + // A duplicate while another delivery works: look again once its lease could have run out. + await ports.jobs.enqueue({ ...job }, { delaySeconds: 300 }) + return + } + if (!run) return + const { db } = ports + const next = run.seq + 1 + try { + if (run.status === 'queued') + await updateRun(db, run.id, { status: 'running', startedAt: new Date() }) + const started = run.startedAt ?? new Date() + const base = run.stats ?? emptyStats() + const checkpoint: Checkpoint = async (state, stats, marked) => { + const sum = addStats(structuredClone(base), stats) + if (marked !== undefined) sum.marked = marked + await db + .update(schema.cleanupRuns) + .set({ state, stats: sum, lease: new Date(), updatedAt: new Date() }) + .where(and(eq(schema.cleanupRuns.id, run.id), eq(schema.cleanupRuns.seq, run.seq))) + } + const r = await work({ ...run, startedAt: started }, checkpoint) + const stats = addStats(base, r.stats) + if (r.marked !== undefined) stats.marked = r.marked + const waits = r.outcome === 'wait' ? Number(run.state?.waits ?? 0) + 1 : 0 + if (waits > MAX_WAITS) { + await updateRun(db, run.id, { + status: 'failed', + stats, + error: + 'Pushes kept committing for two hours; nothing more was deleted. Run the sweep again.', + finishedAt: new Date(), + lease: null, + }) + return + } + const done = r.outcome === 'done' + await updateRun(db, run.id, { + state: { ...r.state, waits }, + stats, + seq: next, + lease: null, + status: done ? 'done' : r.outcome === 'wait' ? 'waiting' : 'running', + error: + r.outcome === 'wait' + ? 'Waiting for pushes to finish committing (or for cleanup to be unpaused)' + : null, + ...(done ? { finishedAt: new Date() } : {}), + }) + if (!done) { + await ports.jobs.enqueue( + { type: job.type, runId: run.id, seq: next }, + r.outcome === 'wait' ? { delaySeconds: cleanupConfig.waitSeconds } : undefined, + ) + } else if (run.step === 'mark' && run.state?.thenSweep) { + await startRun(ports, 'sweep', { + trigger: run.trigger, + dryRun: run.dryRun, + requestedBy: run.requestedBy, + markRunId: run.id, + }) + } + } catch (err) { + console.error(`[cleanup] ${run.step} run ${run.id} failed:`, err) + await updateRun(db, run.id, { + status: 'failed', + error: String((err as Error)?.message ?? err).slice(0, 1000), + finishedAt: new Date(), + lease: null, + }) + } +} + +registerJob('cleanup.internal', async (job, ports) => + runJob(ports, { type: job.type, runId: String(job.runId), seq: Number(job.seq) }, async (run) => { + const r = await cleanInternal(ports, { dryRun: run.dryRun }) + // A dry run would see the same batch every time: one is the answer. + return { state: {}, stats: r.stats, outcome: r.done || run.dryRun ? 'done' : 'more' } + }), +) + +registerJob('cleanup.mark', async (job, ports) => + runJob( + ports, + { type: job.type, runId: String(job.runId), seq: Number(job.seq) }, + async (run, checkpoint) => { + const internal = ports.stores.internal + const thenSweep = run.state?.thenSweep ?? false + const marks = new MarkSet() + await marks.load(internal, run.id, 'mark') + const state = (run.state ?? firstMarkState()) as unknown as MarkState + const r = await markStep(ports, marks, state, async (place, stats) => { + // Shard first: a place never runs ahead of the marks saved for it. + await marks.save(internal, run.id, 'mark') + await checkpoint({ ...place, thenSweep }, stats, marks.size) + }) + await marks.save(internal, run.id, 'mark') + return { + state: { ...r.state, thenSweep }, + stats: r.stats, + outcome: r.state.phase === 'done' ? 'done' : 'more', + marked: marks.size, + } + }, + ), +) + +registerJob('cleanup.sweep', async (job, ports) => + runJob(ports, { type: job.type, runId: String(job.runId), seq: Number(job.seq) }, async (run) => { + const internal = ports.stores.internal + const marks = new MarkSet() + await marks.load(internal, run.markRunId!, 'mark') + await marks.load(internal, run.id, 'remark') + const state = run.state as unknown as SweepState + const r = await sweepStep(ports, run.id, marks, state, run.dryRun) + await marks.save(internal, run.id, 'remark') + return { state: { ...r.state }, stats: r.stats, outcome: r.outcome, marked: marks.size } + }), +) + +/** UTC midnight today: the cron's step 1 adds to one run row per day. */ +const today = () => { + const d = new Date() + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())) +} + +/** What the maintenance cron does for cleanup, every tick. */ +export async function cleanupTick(ports: Ports): Promise { + const { db } = ports + // Step 1, one batch. A day's automatic batches share a run row, when they did anything. + const r = await cleanInternal(ports) + const did = Object.keys(r.stats.deleted).length > 0 || r.stats.problems.length > 0 + if (did) { + const [row] = await db + .select() + .from(schema.cleanupRuns) + .where( + and( + eq(schema.cleanupRuns.step, 'internal'), + eq(schema.cleanupRuns.trigger, 'schedule'), + gt(schema.cleanupRuns.createdAt, today()), + ), + ) + .limit(1) + if (row) { + await updateRun(db, row.id, { + stats: addStats(row.stats ?? emptyStats(), r.stats), + finishedAt: new Date(), + }) + } else { + await db.insert(schema.cleanupRuns).values({ + step: 'internal', + trigger: 'schedule', + status: 'done', + stats: r.stats, + startedAt: new Date(), + finishedAt: new Date(), + updatedAt: new Date(), + }) + } + } + + // Runs whose job chain broke (a message lost after its retries): requeue. + const stale = await db + .select() + .from(schema.cleanupRuns) + .where( + and( + inArray(schema.cleanupRuns.status, ACTIVE), + lt(schema.cleanupRuns.updatedAt, new Date(Date.now() - 2 * LEASE_MS)), + ), + ) + for (const run of stale) { + await updateRun(db, run.id, {}) + await ports.jobs.enqueue({ type: `cleanup.${run.step}`, runId: run.id, seq: run.seq }) + } + + // Working objects (mark shards) of runs long finished. + const old = await db + .select({ id: schema.cleanupRuns.id }) + .from(schema.cleanupRuns) + .where( + and( + inArray(schema.cleanupRuns.step, ['mark', 'sweep']), + inArray(schema.cleanupRuns.status, ['done', 'failed']), + lt(schema.cleanupRuns.finishedAt, new Date(Date.now() - KEEP_WORK_MS)), + sql`${schema.cleanupRuns.state} NOT LIKE '%"workDeleted":true%'`, + ), + ) + .limit(5) + for (const run of old) { + const gone = await deletePrefix(ports.stores.internal, `${runDir(run.id)}/`, false, 500) + if (!gone.complete) break + const current = await getRun(db, run.id) + await updateRun(db, run.id, { state: { ...current?.state, workDeleted: true } }) + } + + // The weekly automatic mark and sweep, when switched on. + const [setting] = await db + .select({ value: schema.instanceSettings.value }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.key, AUTO_SETTING)) + if (setting?.value !== true) return + const [last] = await db + .select({ createdAt: schema.cleanupRuns.createdAt }) + .from(schema.cleanupRuns) + .where(and(eq(schema.cleanupRuns.step, 'mark'), eq(schema.cleanupRuns.trigger, 'schedule'))) + .orderBy(desc(schema.cleanupRuns.createdAt)) + .limit(1) + if (last && Date.now() - last.createdAt.getTime() < cleanupConfig.autoEveryMs) return + await startRun(ports, 'mark', { trigger: 'schedule', thenSweep: true }).catch((err) => { + if (!(err instanceof CleanupRefused)) throw err + }) +} diff --git a/packages/server/src/cleanup/sweep.ts b/packages/server/src/cleanup/sweep.ts new file mode 100644 index 0000000..8525ede --- /dev/null +++ b/packages/server/src/cleanup/sweep.ts @@ -0,0 +1,420 @@ +/** + * Cleanup step 3: sweep (planning: v2-storage-cleanup.md). List the platform + * bucket area by area, keep what the mark (or a row) says is in use, and delete + * the rest inside short deletion windows behind the write fence. + * + * Only these key shapes are ever deleted; anything else is kept and counted as + * unknown: + * + * repository nodes/, bodies/.ndjson.gz, records/.json.gz, + * roots/.json, private/.json: unmarked + * files/: unmarked, with its `files` row + * collections//…: no collection row, no tombstone in grace + * internal sessions//…: no push session row, past the orphan age + * (reference-log scratch, sessions/refs-…, is step 1's) + * uploads/: no upload row, or one that's done, past the orphan age + * + * Repository objects also have to be older than MIN_AGE_MS, so a write phase in + * progress is never the reason for a redo. Schemas, v1 file keys outside the + * repository, the reference log and usage logs are never touched. + * + * Each deletion window: wait until no push is committing (a parallel commit + * can't redo its units), re-mark from versions published since the mark began + * (once before the window, so writers wait as little as possible, and again + * inside it), delete `files` rows, then objects, checking the window's deadline + * as it goes, and close it. + */ +import type { Store } from '@underlay/protocol' +import { and, asc, eq, gt, gte, inArray, or } from 'drizzle-orm' + +import { chunks } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import type { Ports } from '../ports.js' +import { cleanupConfig, cleanupPaused, count, emptyStats, sample } from './config.js' +import { closeWindow, openWindow, type Window, windowStillOpen } from './fence.js' +import { graceCutoff, markRepo, possessionHeld } from './mark.js' +import { Marker, type MarkKind, type MarkSet } from './marks.js' + +/** Repository objects younger than this are never deleted (a write phase may be using them). */ +export const MIN_AGE_MS = 60 * 60 * 1000 +/** Re-marks also cover versions published this long before the mark began (writes that follow a publish). */ +const REMARK_SLACK_MS = 60 * 60 * 1000 + +export const AREAS = [ + { store: 'repo', prefix: 'nodes/' }, + { store: 'repo', prefix: 'bodies/' }, + { store: 'repo', prefix: 'records/' }, + { store: 'repo', prefix: 'roots/' }, + { store: 'repo', prefix: 'private/' }, + { store: 'repo', prefix: 'files/' }, + { store: 'repo', prefix: 'collections/' }, + { store: 'internal', prefix: 'sessions/' }, + { store: 'internal', prefix: 'uploads/' }, +] as const + +export interface SweepState { + /** Index into AREAS, and the listing cursor within it. */ + area: number + cursor: string | null + /** When the mark began. */ + markStartedAt: number +} + +const HEX64 = /^[0-9a-f]{64}$/ + +export interface ParsedKey { + kind: string + /** The hash the mark holds for it, and under which kind (content-addressed kinds). */ + token?: string + mark?: MarkKind + /** The row it belongs to (collections, sessions, uploads). */ + owner?: string +} + +/** What a listed key is, or null for a shape the sweep never deletes. */ +export function parseKey(prefix: string, key: string): ParsedKey | null { + const rest = key.slice(prefix.length) + const hashed = (kind: string, mark: MarkKind, suffix: string): ParsedKey | null => { + if (!rest.endsWith(suffix)) return null + const h = rest.slice(0, rest.length - suffix.length) + return HEX64.test(h) ? { kind, token: h, mark } : null + } + const owned = (kind: string, needsMore: boolean): ParsedKey | null => { + const slash = rest.indexOf('/') + const id = slash < 0 ? rest : rest.slice(0, slash) + if (!id || (needsMore ? slash < 0 : slash >= 0)) return null + return { kind, owner: id } + } + switch (prefix) { + case 'nodes/': + return hashed('nodes', 'n', '') + case 'bodies/': + // A leaf's body goes with the leaf. + return hashed('bodies', 'n', '.ndjson.gz') + case 'records/': + return hashed('records', 'r', '.json.gz') + case 'roots/': + return hashed('roots', 'v', '.json') + case 'private/': + return hashed('private', 'p', '.json') + case 'files/': + return hashed('files', 'f', '') + case 'collections/': + return owned('collections', true) + case 'sessions/': { + const p = owned('sessions', true) + return p && !p.owner!.startsWith('refs-') ? p : null + } + case 'uploads/': + return owned('uploads', false) + } + return null +} + +interface Candidate { + store: Store + key: string + kind: string + token?: string + mark?: MarkKind + owner?: string + size: number +} + +/** The candidates on one listed page: what nothing seems to use. */ +async function classify( + ports: Ports, + marks: MarkSet, + store: Store, + prefix: string, + page: { keys: string[]; info?: { size: number; modified: number }[] }, + stats: schema.CleanupStats, +): Promise { + const { db } = ports + const now = Date.now() + const out: Candidate[] = [] + const owned: Candidate[] = [] + page.keys.forEach((key, i) => { + const p = parseKey(prefix, key) + if (!p) { + if (!(prefix === 'sessions/' && key.startsWith('sessions/refs-'))) stats.unknown++ + return + } + const info = page.info?.[i] + // No write time (none listed, or a store that couldn't say): its age is + // unknown, so it stays. + if (!info || !(info.modified > 0)) return + const age = now - info.modified + const minAge = + p.kind === 'sessions' || p.kind === 'uploads' ? cleanupConfig.orphanAgeMs : MIN_AGE_MS + if (age < minAge) return + const c: Candidate = { store, key, kind: p.kind, size: info.size } + if (p.token) { + if (!marks.has(p.mark!, p.token)) out.push({ ...c, token: p.token, mark: p.mark! }) + } else owned.push({ ...c, owner: p.owner! }) + }) + if (owned.length === 0) return out + + const ids = [...new Set(owned.map((c) => c.owner!))] + const kept = new Set() + for (const part of chunks(ids)) { + const kind = owned[0]!.kind + if (kind === 'collections') { + for (const id of await collectionsInUse(ports, part)) kept.add(id) + } else if (kind === 'sessions') { + const rows = await db + .select({ id: schema.pushSessions.id }) + .from(schema.pushSessions) + .where(inArray(schema.pushSessions.id, part)) + for (const r of rows) kept.add(r.id) + } else { + const rows = await db + .select({ id: schema.fileUploads.id, status: schema.fileUploads.status }) + .from(schema.fileUploads) + .where(inArray(schema.fileUploads.id, part)) + // A pending or verifying upload's staging object is in use (step 1 handles the abandoned). + for (const r of rows) if (r.status === 'pending' || r.status === 'verifying') kept.add(r.id) + } + } + for (const c of owned) if (!kept.has(c.owner!)) out.push(c) + return out +} + +/** Of these collection ids, those with a row, or a tombstone still in its grace period. */ +async function collectionsInUse(ports: Ports, ids: string[]): Promise { + const [live, recent] = await Promise.all([ + ports.db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where(inArray(schema.collections.id, ids)), + ports.db + .select({ id: schema.collectionTombstones.collectionId }) + .from(schema.collectionTombstones) + .where( + and( + inArray(schema.collectionTombstones.collectionId, ids), + gt(schema.collectionTombstones.deletedAt, graceCutoff()), + ), + ), + ]) + return [...live, ...recent].map((r) => r.id) +} + +/** + * Re-mark what changed since the mark began: versions published since (their + * trees and refs trees), collections whose public files tree moved, and + * possessions of the candidate files. + */ +async function remark( + ports: Ports, + marks: MarkSet, + markStartedAt: number, + candidates: Candidate[], +): Promise { + const { db } = ports + const since = new Date(markStartedAt - REMARK_SLACK_MS) + const marker = new Marker(await markRepo(ports), marks) + let after: { at: Date; id: string } | null = null + for (;;) { + const rows: { + id: string + hash: string + at: Date | null + publicRefsRoot: string | null + privateRefsRoot: string | null + }[] = await db + .select({ + id: schema.versions.id, + hash: schema.versions.hash, + at: schema.versions.publishedAt, + publicRefsRoot: schema.versions.publicRefsRoot, + privateRefsRoot: schema.versions.privateRefsRoot, + }) + .from(schema.versions) + .where( + after + ? or( + gt(schema.versions.publishedAt, after.at), + and(eq(schema.versions.publishedAt, after.at), gt(schema.versions.id, after.id)), + ) + : gte(schema.versions.publishedAt, since), + ) + .orderBy(asc(schema.versions.publishedAt), asc(schema.versions.id)) + .limit(200) + for (const v of rows) { + await marker.version(v.hash) + await marker.counts(v.publicRefsRoot) + await marker.counts(v.privateRefsRoot) + } + if (rows.length < 200) break + const last = rows[rows.length - 1]! + after = { at: last.at!, id: last.id } + } + let afterId: string | null = null + for (;;) { + const rows = await db + .select({ id: schema.collections.id, root: schema.collections.publicFilesRoot }) + .from(schema.collections) + .where( + and( + or(gte(schema.collections.updatedAt, since), gte(schema.collections.reconciledAt, since)), + afterId ? gt(schema.collections.id, afterId) : undefined, + ), + ) + .orderBy(asc(schema.collections.id)) + .limit(200) + for (const c of rows) await marker.files(c.root) + if (rows.length < 200) break + afterId = rows[rows.length - 1]!.id + } + const files = candidates.filter((c) => c.kind === 'files').map((c) => c.token!) + for (const part of chunks(files)) { + const rows = await db + .select({ hash: schema.fileUploads.hash }) + .from(schema.fileUploads) + .where( + and( + eq(schema.fileUploads.status, 'verified'), + inArray(schema.fileUploads.hash, part), + possessionHeld(graceCutoff()), + ), + ) + for (const r of rows) marks.add('f', r.hash) + } +} + +/** Count a candidate as deleted, and keep its key (a collection's prefix) as a sample. */ +function counted(stats: schema.CleanupStats, c: Candidate) { + count(stats, c.kind, c.size) + sample(stats, c.kind, c.kind === 'collections' ? `collections/${c.owner}/` : c.key) +} + +/** Pushes committing now (recently started), which a deletion window would fail. */ +async function committing(ports: Ports): Promise { + const [row] = await ports.db + .select({ id: schema.pushSessions.id }) + .from(schema.pushSessions) + .where( + and( + eq(schema.pushSessions.status, 'committing'), + gt( + schema.pushSessions.finalizeStartedAt, + new Date(Date.now() - cleanupConfig.committingHoldMs), + ), + ), + ) + .limit(1) + return !!row +} + +/** + * Delete candidates in deletion windows (or, dry, count them). 'wait' when a + * push is committing or another window is open: the caller tries again later. + */ +async function deleteCandidates( + ports: Ports, + runId: string, + marks: MarkSet, + markStartedAt: number, + candidates: Candidate[], + dryRun: boolean, + stats: schema.CleanupStats, +): Promise<'ok' | 'wait'> { + if (candidates.length === 0) return 'ok' + // Outside a window: catch up on what was published since the mark, so the + // re-mark inside the window is short. + await remark(ports, marks, markStartedAt, candidates) + const live = (c: Candidate) => !!c.token && marks.has(c.mark!, c.token) + if (dryRun) { + for (const c of candidates) if (!live(c)) counted(stats, c) + return 'ok' + } + const { db } = ports + for (let i = 0; i < candidates.length; i += cleanupConfig.windowObjects) { + const group = candidates.slice(i, i + cleanupConfig.windowObjects) + if ((await committing(ports)) || (await cleanupPaused(db))) return 'wait' + const w: Window | null = await openWindow(db, runId, cleanupConfig.windowMs) + if (!w) return 'wait' + try { + stats.windows++ + await remark(ports, marks, markStartedAt, group) + let doomed = group.filter((c) => !live(c)) + // A collection whose row exists by now is live. + const owners = doomed.filter((c) => c.kind === 'collections').map((c) => c.owner!) + if (owners.length) { + const inUse = new Set() + for (const part of chunks([...new Set(owners)])) + for (const id of await collectionsInUse(ports, part)) inUse.add(id) + doomed = doomed.filter((c) => c.kind !== 'collections' || !inUse.has(c.owner!)) + } + // Rows before bytes: a crash between leaves bytes nothing points to, never + // a row pointing at nothing. + const fileKeys = doomed + .filter((c) => c.kind === 'files') + .map((c) => ports.stores.canonicalFileKey(c.token!)) + // Past the window's deadline the rest waits for the next sweep. + let open = true + for (const part of chunks(fileKeys)) { + if (!(open = await windowStillOpen(db, w))) break + const gone = await db + .delete(schema.files) + .where(inArray(schema.files.storageKey, part)) + .returning({ hash: schema.files.hash }) + stats.rows += gone.length + } + for (let j = 0; open && j < doomed.length; j += cleanupConfig.deleteConcurrency) { + if (!(open = await windowStillOpen(db, w))) break + const part = doomed.slice(j, j + cleanupConfig.deleteConcurrency) + await Promise.all(part.map((c) => c.store.delete(c.key))) + for (const c of part) counted(stats, c) + } + } finally { + await closeWindow(db, w) + } + } + return 'ok' +} + +/** + * One sweep job: list pages from `state`, deleting as it goes, until the areas + * are done, the page budget is spent, or it has to wait for pushes. + */ +export async function sweepStep( + ports: Ports, + runId: string, + marks: MarkSet, + state: SweepState, + dryRun: boolean, +): Promise<{ state: SweepState; stats: schema.CleanupStats; outcome: 'more' | 'done' | 'wait' }> { + const stats = emptyStats() + const repo = await ports.stores.forLocation(schema.PLATFORM_LOCATION_ID) + const stores = { repo: repo.blobs, internal: ports.stores.internal } + let s = { ...state } + // Where the candidates gathered so far began: a 'wait' lists them again. + let start = { ...s } + let pending: Candidate[] = [] + const flush = async () => { + const r = await deleteCandidates(ports, runId, marks, s.markStartedAt, pending, dryRun, stats) + pending = [] + return r + } + for (let pages = 0; s.area < AREAS.length && pages < cleanupConfig.sweepPages; pages++) { + const area = AREAS[s.area]! + const store = stores[area.store] + const page = await store.list(area.prefix, s.cursor ?? undefined) + stats.scanned += page.keys.length + pending.push(...(await classify(ports, marks, store, area.prefix, page, stats))) + const next = page.cursor + ? { ...s, cursor: page.cursor } + : { ...s, area: s.area + 1, cursor: null } + if (pending.length >= cleanupConfig.windowObjects || next.area !== s.area) { + if ((await flush()) === 'wait') return { state: start, stats, outcome: 'wait' } + start = { ...next } + } + s = next + const deleted = Object.values(stats.deleted).reduce((n, c) => n + c.objects, 0) + if (deleted >= cleanupConfig.sweepDeletes) break + } + if ((await flush()) === 'wait') return { state: start, stats, outcome: 'wait' } + return { state: s, stats, outcome: s.area >= AREAS.length ? 'done' : 'more' } +} diff --git a/packages/server/src/db/chunks.ts b/packages/server/src/db/chunks.ts new file mode 100644 index 0000000..ba5007f --- /dev/null +++ b/packages/server/src/db/chunks.ts @@ -0,0 +1,24 @@ +import { type SQL, sql } from 'drizzle-orm' + +/** + * D1 binds at most 100 parameters per statement and runs at most 1,000 queries + * per Worker invocation. Short lists of values go into `IN (…)` in chunks of + * IN_CHUNK; long ones (thousands of hashes) as one JSON parameter (inJson). + */ +export const IN_CHUNK = 90 + +/** + * Values per inJson parameter: a bound value is at most 2 MB on D1, and a + * 64-hex hash takes 67 bytes of JSON. + */ +export const JSON_CHUNK = 10_000 + +/** `col IN (the values)`, the values bound as one JSON array: one parameter however many. */ +export const inJson = (col: unknown, values: readonly string[]): SQL => + sql`${col} IN (SELECT value FROM json_each(${JSON.stringify(values)}))` + +export function chunks(xs: readonly T[], size = IN_CHUNK): T[][] { + const out: T[][] = [] + for (let i = 0; i < xs.length; i += size) out.push(xs.slice(i, i + size)) + return out +} diff --git a/packages/server/src/db/d1.ts b/packages/server/src/db/d1.ts new file mode 100644 index 0000000..9a98876 --- /dev/null +++ b/packages/server/src/db/d1.ts @@ -0,0 +1,12 @@ +/** SQLite on Cloudflare: D1. Migrations are applied by wrangler, not at runtime. */ +import type { D1Database } from '@cloudflare/workers-types' +import { drizzle } from 'drizzle-orm/d1' + +import type { Db } from '../ports.js' +import * as schema from './schema.js' + +export function openD1(binding: D1Database): Db { + // The D1 and libsql drivers build the same queries and both support batch(); + // the app is typed against one of them. + return drizzle(binding, { schema }) as unknown as Db +} diff --git a/packages/server/src/db/node.ts b/packages/server/src/db/node.ts new file mode 100644 index 0000000..5050d1e --- /dev/null +++ b/packages/server/src/db/node.ts @@ -0,0 +1,74 @@ +/** SQLite on Node, through libsql: async with atomic batches, the same shape as D1. */ +import { fileURLToPath } from 'node:url' + +import { type Client, createClient, type InStatement } from '@libsql/client' +import { drizzle } from 'drizzle-orm/libsql' +import { migrate } from 'drizzle-orm/libsql/migrator' + +import type { Db } from '../ports.js' +import * as schema from './schema.js' + +const migrationsFolder = fileURLToPath(new URL('../../drizzle', import.meta.url)) + +/** D1's limit on bound parameters per statement. libsql allows ~32k, so tests enforce it. */ +export const D1_MAX_BOUND_PARAMS = 100 + +export interface NodeDbOptions { + /** Throw on any statement binding more parameters than this (tests: D1's 100). */ + maxBoundParams?: number + /** Called for every statement sent (tests count queries per request). */ + onStatement?: () => void + /** + * Apply the migrations (default true). False for a database whose schema came + * from elsewhere, e.g. a `wrangler d1 export`, which has no drizzle bookkeeping. + */ + migrate?: boolean +} + +/** Open (and normally migrate) a database. `url` is `file:path/to/db.sqlite`, or `:memory:`. */ +export async function openNodeDb(url: string, opts: NodeDbOptions = {}): Promise { + let client = createClient({ url }) + if (url !== ':memory:') await client.execute('PRAGMA journal_mode = WAL') + await client.execute('PRAGMA foreign_keys = ON') + await client.execute('PRAGMA busy_timeout = 5000') + if (opts.maxBoundParams !== undefined || opts.onStatement) + client = guardParams(client, opts.maxBoundParams ?? Infinity, opts.onStatement) + const db = drizzle(client, { schema }) + if (opts.migrate !== false) await migrate(db, { migrationsFolder }) + return db +} + +function boundParams(stmt: InStatement): number { + if (typeof stmt === 'string' || !stmt.args) return 0 + return Array.isArray(stmt.args) ? stmt.args.length : Object.keys(stmt.args).length +} + +/** A client that refuses statements D1 would refuse for their parameter count. */ +function guardParams(client: Client, max: number, onStatement?: () => void): Client { + const check = (stmt: InStatement) => { + onStatement?.() + const n = boundParams(stmt) + if (n > max) { + const text = typeof stmt === 'string' ? stmt : stmt.sql + throw new Error(`Statement binds ${n} parameters (D1 allows ${max}): ${text.slice(0, 200)}`) + } + } + return new Proxy(client, { + get(target, prop, receiver) { + if (prop === 'execute') { + return (stmt: InStatement, ...rest: unknown[]) => { + check(stmt) + return (target.execute as (...a: unknown[]) => unknown).call(target, stmt, ...rest) + } + } + if (prop === 'batch') { + return (stmts: InStatement[], ...rest: unknown[]) => { + stmts.forEach(check) + return (target.batch as (...a: unknown[]) => unknown).call(target, stmts, ...rest) + } + } + const value = Reflect.get(target, prop, receiver) as unknown + return typeof value === 'function' ? (value as Function).bind(target) : value + }, + }) +} diff --git a/packages/server/src/db/replicas.ts b/packages/server/src/db/replicas.ts new file mode 100644 index 0000000..96f2e90 --- /dev/null +++ b/packages/server/src/db/replicas.ts @@ -0,0 +1,13 @@ +/** + * D1 read replication (v2-scale-review.md S8): which requests any replica may + * answer. An anonymous GET or HEAD may read slightly behind the primary; a + * signed-in user, an API key (header or ?token=), a write, and sign-in itself + * read the primary, so they always see their own writes without bookmarks. + */ +export function readsAnyReplica(req: Request): boolean { + if (req.method !== 'GET' && req.method !== 'HEAD') return false + const url = new URL(req.url) + if (url.pathname.startsWith('/api/auth/') || url.pathname === '/login') return false + if (req.headers.has('authorization') || url.searchParams.has('token')) return false + return !/session_token=/.test(req.headers.get('cookie') ?? '') +} diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts new file mode 100644 index 0000000..00631a1 --- /dev/null +++ b/packages/server/src/db/schema.ts @@ -0,0 +1,987 @@ +/** + * Mutable state, in SQLite (D1 on Cloudflare, libsql on Node). + * + * Rule: rows are small and bounded. Anything whose size the user controls — + * version roots, metadata, READMEs, schemas, file lists — lives in the blob store + * and is referenced by hash. Anything that grows with data (records, version + * membership, provenance) lives in the blob store too. See edge-redesign.md, + * "SQLite (mutable state)". + * + * D1 has no interactive transactions, only atomic batches, so every + * read-modify-write is written as a batch whose conditions are in SQL (see + * publishVersion in ../versions/publish.ts). + */ +import { sql } from 'drizzle-orm' +import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' + +const id = () => + text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()) +const createdAt = () => + integer('created_at', { mode: 'timestamp_ms' }) + .notNull() + .default(sql`(unixepoch('subsec') * 1000)`) +const ts = (name: string) => integer(name, { mode: 'timestamp_ms' }) +const bool = (name: string) => integer(name, { mode: 'boolean' }) +const json = (name: string) => text(name, { mode: 'json' }).$type() + +/** A schema usage row as reconcile rebuilds it. */ +export interface UsageSpan { + schemaHash: string + typeSlug: string + set: 'public' | 'private' + fromSeq: number + toSeq: number | null +} + +/** A reconcile run's progress (collections.reconcile_state). */ +export interface ReconcileState { + /** Bumped by each job; a job whose step isn't current is a duplicate delivery. */ + step: number + /** Every version's totals again (a steward's run), or only those never checked. */ + full: boolean + stage: 'versions' | 'collection' + /** The last seq handled in this stage. */ + afterSeq: number + /** collection stage: the write fence's epoch when it began. */ + fence?: number + /** collection stage: files are rebuilt from this seq's checkpoint (0: from nothing). */ + filesFrom?: number + /** collection stage: the cumulative public files tree through afterSeq. */ + filesRoot?: string | null + /** collection stage: version afterSeq's own public files tree, to diff the next against. */ + prevFiles?: string | null + /** collection stage: schema usage replayed through afterSeq (open rows have toSeq null). */ + usage?: UsageSpan[] +} + +/** One counter a reconcile found wrong, and the value it wrote. */ +export interface ReconcileDiff { + field: string + /** The version, for per-version counters. */ + seq?: number + was: unknown + now: unknown +} + +// --- better-auth (same fields as v1, so sessions and keys port across) ------- + +export const user = sqliteTable('user', { + id: id(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: bool('email_verified').notNull().default(false), + image: text('image'), + createdAt: createdAt(), + updatedAt: ts('updated_at') + .notNull() + .$defaultFn(() => new Date()), +}) + +export const session = sqliteTable( + 'session', + { + id: id(), + expiresAt: ts('expires_at').notNull(), + token: text('token').notNull().unique(), + createdAt: createdAt(), + updatedAt: ts('updated_at') + .notNull() + .$defaultFn(() => new Date()), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + activeOrganizationId: text('active_organization_id'), + }, + (t) => [index('session_user_id_idx').on(t.userId)], +) + +export const account = sqliteTable( + 'account', + { + id: id(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: ts('access_token_expires_at'), + refreshTokenExpiresAt: ts('refresh_token_expires_at'), + scope: text('scope'), + password: text('password'), + createdAt: createdAt(), + updatedAt: ts('updated_at') + .notNull() + .$defaultFn(() => new Date()), + }, + (t) => [index('account_user_id_idx').on(t.userId)], +) + +export const verification = sqliteTable( + 'verification', + { + id: id(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: ts('expires_at').notNull(), + createdAt: createdAt(), + updatedAt: ts('updated_at') + .notNull() + .$defaultFn(() => new Date()), + }, + (t) => [index('verification_identifier_idx').on(t.identifier)], +) + +export const organization = sqliteTable('organization', { + id: id(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), + logo: text('logo'), + createdAt: createdAt(), + metadata: text('metadata'), + bio: text('bio'), + website: text('website'), + avatarUrl: text('avatar_url'), + arkNaan: text('ark_naan'), + kfOrgId: text('kf_org_id'), + isDefault: bool('is_default').default(false), +}) + +export const member = sqliteTable( + 'member', + { + id: id(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + role: text('role').notNull().default('member'), + createdAt: createdAt(), + }, + (t) => [ + index('member_organization_id_idx').on(t.organizationId), + index('member_user_id_idx').on(t.userId), + ], +) + +export const invitation = sqliteTable( + 'invitation', + { + id: id(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + role: text('role'), + status: text('status').notNull().default('pending'), + expiresAt: ts('expires_at').notNull(), + createdAt: createdAt(), + inviterId: text('inviter_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + }, + (t) => [index('invitation_organization_id_idx').on(t.organizationId)], +) + +export const apikey = sqliteTable( + 'apikey', + { + id: id(), + configId: text('config_id').notNull().default('default'), + name: text('name'), + start: text('start'), + referenceId: text('reference_id').notNull(), + prefix: text('prefix'), + key: text('key').notNull(), + refillInterval: integer('refill_interval'), + refillAmount: integer('refill_amount'), + lastRefillAt: ts('last_refill_at'), + enabled: bool('enabled').default(true), + rateLimitEnabled: bool('rate_limit_enabled').default(true), + rateLimitTimeWindow: integer('rate_limit_time_window').default(86_400_000), + rateLimitMax: integer('rate_limit_max').default(10), + requestCount: integer('request_count').default(0), + remaining: integer('remaining'), + lastRequest: ts('last_request'), + expiresAt: ts('expires_at'), + createdAt: createdAt(), + updatedAt: ts('updated_at') + .notNull() + .$defaultFn(() => new Date()), + permissions: text('permissions'), + metadata: text('metadata'), + }, + (t) => [index('apikey_key_idx').on(t.key), index('apikey_reference_id_idx').on(t.referenceId)], +) + +// --- Collections and versions ------------------------------------------------- + +/** What lists and explore show without reading a root: bounded, refreshed at publish. */ +export interface CollectionSummary { + title?: string + description?: string + tags?: string[] + license?: string +} + +export const collections = sqliteTable( + 'collections', + { + id: id(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + slug: text('slug').notNull(), + name: text('name').notNull(), + public: bool('public').notNull().default(false), + /** The head: latest published version. Advanced only by compare-and-swap. */ + headVersionId: text('head_version_id'), + /** Per-collection salt for the private-set commitment (32 bytes hex). */ + privateSalt: text('private_salt').notNull(), + /** Cumulative public file tree over every published version (file access checks). */ + publicFilesRoot: text('public_files_root'), + summary: json('summary'), + /** Billing counters for the reference log (decision 21); rebuildable from version diffs. */ + refEvents: integer('ref_events').notNull().default(0), + refBytes: integer('ref_bytes').notNull().default(0), + /** + * History totals, kept in the publish batch so steward stats read one row a + * collection instead of every version; reconcile checks them. + */ + versionCount: integer('version_count').notNull().default(0), + historyBytes: integer('history_bytes').notNull().default(0), + lastPushAt: ts('last_push_at'), + /** Reconcile (billing/reconcile.ts): started, finished, and what it corrected. */ + reconcileStartedAt: ts('reconcile_started_at'), + reconciledAt: ts('reconciled_at'), + reconcileReport: json('reconcile_report'), + /** A run in progress: where its job chain is, and what it has built so far. */ + reconcileState: json('reconcile_state'), + /** + * The last finished run's checkpoint: the head seq it reached and the public + * files tree it verified there. The next run rebuilds that tree from here. + */ + reconciledSeq: integer('reconciled_seq').notNull().default(0), + reconciledFilesRoot: text('reconciled_files_root'), + deletedAt: ts('deleted_at'), + createdAt: createdAt(), + updatedAt: ts('updated_at') + .notNull() + .$defaultFn(() => new Date()), + }, + (t) => [ + uniqueIndex('collections_org_slug_uq').on(t.organizationId, t.slug), + index('collections_public_updated_idx').on(t.public, t.updatedAt), + ], +) + +export const versions = sqliteTable( + 'versions', + { + id: id(), + collectionId: text('collection_id') + .notNull() + .references(() => collections.id, { onDelete: 'cascade' }), + /** 1, 2, 3… per collection: the order of publication. */ + seq: integer('seq').notNull(), + semver: text('semver').notNull(), + major: integer('major').notNull(), + minor: integer('minor').notNull(), + patch: integer('patch').notNull(), + /** `ulv2:`; the root document is roots/.json in the blob store. */ + hash: text('hash').notNull(), + baseSemver: text('base_semver'), + message: text('message'), + pushedBy: text('pushed_by'), + appId: text('app_id'), + actorId: text('actor_id'), + signature: text('signature'), + /** Cached from the root: totals over both sets, and per type. */ + recordCount: integer('record_count').notNull(), + publicRecordCount: integer('public_record_count').notNull(), + fileCount: integer('file_count').notNull(), + totalBytes: integer('total_bytes').notNull(), + /** What non-owners see: the public set's file count and record + file bytes. */ + publicFileCount: integer('public_file_count').notNull().default(0), + publicTotalBytes: integer('public_total_bytes').notNull().default(0), + typeCounts: json>('type_counts').notNull(), + publicTypeCounts: json>('public_type_counts').notNull(), + hasPrivate: bool('has_private').notNull().default(false), + /** + * Roots of the per-set file reference count trees (not protocol): how many of + * the set's records reference each file, plus declared-file markers. They let + * the next commit keep the file sets right in O(changes). + */ + publicRefsRoot: text('public_refs_root'), + privateRefsRoot: text('private_refs_root'), + /** The reference log has this version's events (written by the refs.index job). */ + refsIndexed: bool('refs_indexed').notNull().default(false), + /** Its events and their billable bytes, once indexed (null on rows indexed before 0011). */ + refEvents: integer('ref_events'), + refBytes: integer('ref_bytes'), + /** Last reconcile (billing/reconcile.ts), and what it corrected, if anything. */ + reconciledAt: ts('reconciled_at'), + reconcileReport: json('reconcile_report'), + /** Change counts against the previous version (drive semver and webhooks). */ + changes: json<{ added: number; removed: number; updated: number }>('changes'), + createdAt: createdAt(), + /** + * When this instance published it. Unlike createdAt, never historical (migration + * keeps the original time there). Storage cleanup re-marks from + * versions published since its mark began; null on rows from before 0015. + */ + publishedAt: ts('published_at'), + /** + * The push session that committed it (null for other commits). A commit + * that runs twice finds its own version by this (push/outcome.ts). + */ + pushSessionId: text('push_session_id'), + }, + (t) => [ + uniqueIndex('versions_collection_seq_uq').on(t.collectionId, t.seq), + uniqueIndex('versions_collection_semver_uq').on(t.collectionId, t.semver), + index('versions_hash_idx').on(t.hash), + index('versions_published_idx').on(t.publishedAt), + index('versions_push_session_idx').on(t.pushSessionId), + ], +) + +export const forks = sqliteTable('forks', { + childCollectionId: text('child_collection_id') + .primaryKey() + .references(() => collections.id, { onDelete: 'cascade' }), + parentCollectionId: text('parent_collection_id').notNull(), + parentSeq: integer('parent_seq').notNull(), + /** The sets the fork carried: a non-member's fork takes the public set only. */ + sets: text('sets', { enum: ['public', 'public+private'] }) + .notNull() + .default('public'), + createdAt: createdAt(), +}) + +// --- Schemas --------------------------------------------------------------------- + +/** Known schemas. The body is schemas/.json in the blob store. */ +export const schemas = sqliteTable('schemas', { + hash: text('hash').primaryKey(), + createdAt: createdAt(), +}) + +export const schemaLabels = sqliteTable( + 'schema_labels', + { + schemaHash: text('schema_hash').notNull(), + label: text('label').notNull(), + createdAt: createdAt(), + }, + (t) => [ + primaryKey({ columns: [t.schemaHash, t.label] }), + index('schema_labels_label_idx').on(t.label), + ], +) + +/** + * Where each schema is used: one row per run of versions in which a collection's + * type uses the schema in a set. Opened when a type starts using a schema, closed + * (`toSeq`) when it stops, so writes are O(changes). Rebuildable from the roots. + */ +export const schemaUsage = sqliteTable( + 'schema_usage', + { + schemaHash: text('schema_hash').notNull(), + collectionId: text('collection_id') + .notNull() + .references(() => collections.id, { onDelete: 'cascade' }), + typeSlug: text('type_slug').notNull(), + set: text('set', { enum: ['public', 'private'] }).notNull(), + fromSeq: integer('from_seq').notNull(), + /** First seq that no longer uses it; null while current. */ + toSeq: integer('to_seq'), + }, + (t) => [ + primaryKey({ columns: [t.collectionId, t.typeSlug, t.set, t.fromSeq] }), + index('schema_usage_hash_idx').on(t.schemaHash), + ], +) + +// --- Storage locations and placements (edge-redesign.md, "Placements") ----------------- + +/** + * Where repositories can live. The platform location's bucket and credentials + * come from the deployment's bindings and secrets; customer locations carry + * their own (encrypted with the platform key, never returned to clients). + */ +export const storageLocations = sqliteTable('storage_locations', { + id: id(), + /** Owning org; null for platform locations. */ + organizationId: text('organization_id').references(() => organization.id, { + onDelete: 'cascade', + }), + kind: text('kind', { enum: ['platform', 's3'] }).notNull(), + name: text('name').notNull(), + endpoint: text('endpoint'), + region: text('region'), + bucket: text('bucket'), + prefix: text('prefix').notNull().default(''), + /** Encrypted JSON {accessKeyId, secretAccessKey}; null for platform locations. */ + credentials: text('credentials'), + status: text('status', { enum: ['active', 'unverified', 'broken', 'disabled'] }) + .notNull() + .default('unverified'), + lastError: text('last_error'), + verifiedAt: ts('verified_at'), + /** Last check, passed or not; the cron sweep re-checks locations daily. */ + checkedAt: ts('checked_at'), + createdAt: createdAt(), +}) + +/** A mirror copy in progress: version `seq`, stopped in tree `tree` after key `after`. */ +export interface MirrorCursor { + seq: number + /** -1: schemas next; 0…n-1: that tree; n: files; n+1: finishing. */ + tree: number + after: string | null +} + +/** The id of the deployment's own location, seeded by the first migration. */ +export const PLATFORM_LOCATION_ID = 'platform' + +/** + * Which locations hold a collection: exactly one primary (today always a + * platform location) and any number of mirrors. A row with `organizationId` and + * no `collectionId` is an org-wide default inherited by the org's collections. + */ +export const placements = sqliteTable( + 'placements', + { + id: id(), + collectionId: text('collection_id').references(() => collections.id, { onDelete: 'cascade' }), + organizationId: text('organization_id').references(() => organization.id, { + onDelete: 'cascade', + }), + locationId: text('location_id') + .notNull() + .references(() => storageLocations.id, { onDelete: 'cascade' }), + role: text('role', { enum: ['primary', 'mirror'] }).notNull(), + sets: text('sets', { enum: ['public', 'public+private'] }).notNull(), + state: text('state', { enum: ['active', 'backfilling', 'lagging', 'error', 'paused'] }) + .notNull() + .default('active'), + /** The last version (seq) fully copied to this location. */ + syncedSeq: integer('synced_seq').notNull().default(0), + /** Where a copy of the next version stopped, to resume (locations/mirror.ts). */ + cursor: json('cursor'), + /** Held by the job copying to this placement: one copy at a time. */ + leaseUntil: ts('lease_until'), + lastError: text('last_error'), + updatedAt: ts('updated_at') + .notNull() + .$defaultFn(() => new Date()), + }, + (t) => [ + uniqueIndex('placements_one_primary_uq') + .on(t.collectionId) + .where(sql`${t.role} = 'primary' AND ${t.collectionId} IS NOT NULL`), + // One per target kind: exactly one of collection_id and organization_id is set, and + // SQLite treats NULLs as distinct, so a single index over both would never fire. + uniqueIndex('placements_collection_location_uq') + .on(t.collectionId, t.locationId) + .where(sql`${t.collectionId} IS NOT NULL`), + uniqueIndex('placements_org_location_uq') + .on(t.organizationId, t.locationId) + .where(sql`${t.organizationId} IS NOT NULL`), + index('placements_location_idx').on(t.locationId), + ], +) + +// --- Files ------------------------------------------------------------------------- + +export const files = sqliteTable('files', { + hash: text('hash').primaryKey(), + size: integer('size').notNull(), + mimeType: text('mime_type').notNull(), + storageKey: text('storage_key').notNull(), + verifiedAt: ts('verified_at'), + createdAt: createdAt(), +}) + +/** Pending direct uploads: verified by a job before the file row exists. */ +export const fileUploads = sqliteTable( + 'file_uploads', + { + id: id(), + collectionId: text('collection_id').notNull(), + sessionId: text('session_id'), + hash: text('hash').notNull(), + size: integer('size').notNull(), + mimeType: text('mime_type').notNull(), + storageKey: text('storage_key').notNull(), + multipartUploadId: text('multipart_upload_id'), + status: text('status', { enum: ['pending', 'verifying', 'verified', 'failed'] }) + .notNull() + .default('pending'), + error: text('error'), + createdAt: createdAt(), + }, + (t) => [index('file_uploads_hash_idx').on(t.hash)], +) + +/** Hashes that are never served (abuse). Checked on file redirects and record reads. */ +export const denylist = sqliteTable('denylist', { + hash: text('hash').primaryKey(), + kind: text('kind', { enum: ['file', 'record'] }).notNull(), + reason: text('reason').notNull(), + createdAt: createdAt(), +}) + +/** + * Reports of content that shouldn't be served (POST /api/abuse-reports). A + * steward reviews them and blocks a hash through the denylist, or dismisses. + */ +export const abuseReports = sqliteTable( + 'abuse_reports', + { + id: id(), + /** A file or record hash, when the reporter has one. */ + hash: text('hash'), + /** The page or link the report is about. */ + url: text('url'), + reason: text('reason').notNull(), + /** How to reach the reporter (optional). */ + contact: text('contact'), + /** Set when the reporter was signed in. */ + reporterId: text('reporter_id'), + status: text('status', { enum: ['open', 'blocked', 'dismissed'] }) + .notNull() + .default('open'), + resolvedBy: text('resolved_by'), + resolvedAt: ts('resolved_at'), + createdAt: createdAt(), + }, + (t) => [index('abuse_reports_status_idx').on(t.status, t.createdAt)], +) + +// --- Push sessions ------------------------------------------------------------------- + +export type SessionStatus = 'open' | 'committing' | 'committed' | 'failed' | 'expired' + +/** + * A push in progress. Session inputs whose size the user controls (schemas, + * metadata, the declared file list) are in the blob store under sessions//; + * runs of uploaded manifest entries and records are sessions//runs/…. + */ +export const pushSessions = sqliteTable( + 'push_sessions', + { + id: id(), + collectionId: text('collection_id') + .notNull() + .references(() => collections.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), + baseVersionId: text('base_version_id'), + baseSemver: text('base_semver'), + message: text('message'), + appId: text('app_id'), + actorId: text('actor_id'), + stripUnknownFields: bool('strip_unknown_fields').notNull().default(false), + recordsReceived: integer('records_received').notNull().default(0), + runs: integer('runs').notNull().default(0), + status: text('status').$type().notNull().default('open'), + result: json>('result'), + error: json<{ statusCode: number; error: string; [k: string]: unknown }>('error'), + finalizeStartedAt: ts('finalize_started_at'), + /** When storage cleanup deleted the session's objects (sessions//) and run rows. */ + cleanedAt: ts('cleaned_at'), + /** A parallel commit's plan (push/parallel.ts): set once, when its units are queued. */ + commitPlan: text('commit_plan'), + /** Held by the `commit.assemble` job while it runs (one assembler at a time). */ + assemblyLease: ts('assembly_lease'), + createdAt: createdAt(), + expiresAt: ts('expires_at').notNull(), + }, + (t) => [ + index('push_sessions_collection_idx').on(t.collectionId), + index('push_sessions_expires_idx').on(t.status, t.expiresAt), + index('push_sessions_user_idx').on(t.userId, t.status), + ], +) + +/** One uploaded batch, stored as a sorted run object. */ +export const pushRuns = sqliteTable( + 'push_runs', + { + sessionId: text('session_id') + .notNull() + .references(() => pushSessions.id, { onDelete: 'cascade' }), + seq: integer('seq').notNull(), + kind: text('kind', { enum: ['records', 'deletes'] }).notNull(), + objectKey: text('object_key').notNull(), + count: integer('count').notNull(), + /** Run key range, `type\u0000id`, for planning commit units. */ + firstKey: text('first_key').notNull(), + lastKey: text('last_key').notNull(), + /** 0 for an upload; n for a run compacted from tier n − 1 (push/compact.ts). */ + tier: integer('tier').notNull().default(0), + /** Claimed by the compaction writing run `mergingInto`; null while free. */ + mergingInto: integer('merging_into'), + createdAt: createdAt(), + }, + (t) => [ + primaryKey({ columns: [t.sessionId, t.seq] }), + index('push_runs_tier_idx').on(t.sessionId, t.tier, t.mergingInto), + ], +) + +export type CommitUnitStatus = 'pending' | 'done' | 'failed' | 'superseded' + +/** + * One key range of one (set, type) tree in a parallel commit (push/parallel.ts). + * A gap is a range with no changes, left to assembly. A unit whose `through` + * was deleted ends `failed`; assembly merges it with the following ranges into + * a new unit and marks the old ones `superseded`. + */ +export const commitUnits = sqliteTable( + 'commit_units', + { + id: id(), + sessionId: text('session_id') + .notNull() + .references(() => pushSessions.id, { onDelete: 'cascade' }), + planId: text('plan_id').notNull(), + set: text('set', { enum: ['public', 'private'] }).notNull(), + type: text('type').notNull(), + /** Position in the tree's key order; a merged unit takes its first member's. */ + ord: integer('ord').notNull(), + /** The range `(after, through]`; null is unbounded. */ + after: text('after'), + through: text('through'), + gap: bool('gap').notNull().default(false), + status: text('status').$type().notNull().default('pending'), + /** The unit's run slices (the blocks it reads), in the internal area. */ + slicesKey: text('slices_key'), + /** Leaves, change counts and file reference deltas, in the internal area. */ + outputKey: text('output_key'), + /** When the unit was put on the queue; null while it waits for room (push/parallel.ts). */ + queuedAt: ts('queued_at'), + createdAt: createdAt(), + }, + (t) => [ + index('commit_units_plan_idx').on(t.planId, t.set, t.type, t.ord), + index('commit_units_status_idx').on(t.sessionId, t.status, t.queuedAt), + ], +) + +/** + * Usage per day, account, collection and metric (edge-redesign.md, Metering): + * derived from the usage log (`usage//…` objects in the internal area), and + * rebuildable from it (billing/usage.ts). `collection_id` is '' when none applies. + */ +export const usageRollups = sqliteTable( + 'usage_rollups', + { + day: text('day').notNull(), + accountId: text('account_id').notNull(), + collectionId: text('collection_id').notNull(), + metric: text('metric').notNull(), + amount: integer('amount').notNull(), + }, + (t) => [ + primaryKey({ columns: [t.day, t.accountId, t.collectionId, t.metric] }), + index('usage_rollups_account_idx').on(t.accountId, t.day), + ], +) + +/** + * A deleted collection (edge-redesign.md, Provenance and Metering): its id, owner + * and slug at deletion, and its counters as they stood, which the deletion zeroes + * along with the rows. Reference-log compaction drops the events of a tombstoned + * id that no collection holds any more; restoring the collection lifts it. + */ +export const collectionTombstones = sqliteTable('collection_tombstones', { + collectionId: text('collection_id').primaryKey(), + organizationId: text('organization_id').notNull(), + slug: text('slug').notNull(), + refEvents: integer('ref_events').notNull(), + refBytes: integer('ref_bytes').notNull(), + /** Versions and their logical bytes at deletion (the version rows go with the collection). */ + versions: integer('versions').notNull(), + totalBytes: integer('total_bytes').notNull(), + deletedBy: text('deleted_by'), + deletedAt: ts('deleted_at') + .notNull() + .$defaultFn(() => new Date()), +}) + +// --- Reference log (provenance; edge-redesign.md "Provenance: the reference log") -------- + +/** + * The manifest of the reference log: one row per immutable segment in the + * platform's internal area. A segment holds events sorted by hash; segments of + * one run cover disjoint hash ranges. Size-tiered compaction merges runs of a + * tier into one run of the next, so a query reads O(log n) runs. + */ +export const refSegments = sqliteTable( + 'ref_segments', + { + id: id(), + runId: text('run_id').notNull(), + tier: integer('tier').notNull(), + firstHash: text('first_hash').notNull(), + lastHash: text('last_hash').notNull(), + count: integer('count').notNull(), + bytes: integer('bytes').notNull(), + /** pending: written by an unfinished compaction; live: queried; retired: replaced. */ + state: text('state', { enum: ['pending', 'live', 'retired'] }) + .notNull() + .default('live'), + createdAt: createdAt(), + }, + (t) => [ + index('ref_segments_range_idx').on(t.state, t.firstHash, t.lastHash), + index('ref_segments_run_idx').on(t.runId), + index('ref_segments_tier_idx').on(t.tier), + ], +) + +/** A compaction merging runs of one tier, split into hash-range parts run as jobs. */ +export const refCompactions = sqliteTable('ref_compactions', { + id: id(), + tier: integer('tier').notNull(), + inputRuns: json('input_runs').notNull(), + outputRun: text('output_run').notNull(), + parts: integer('parts').notNull(), + /** Hex prefix length of each part's hash range. */ + prefixLength: integer('prefix_length').notNull(), + status: text('status', { enum: ['running', 'done'] }) + .notNull() + .default('running'), + createdAt: createdAt(), +}) + +/** Units of a version being indexed in parts (refs/log.ts): done units and their counts. */ +export const refIndexUnits = sqliteTable( + 'ref_index_units', + { + versionId: text('version_id').notNull(), + unit: integer('unit').notNull(), + events: integer('events').notNull(), + bytes: integer('bytes').notNull(), + }, + (t) => [primaryKey({ columns: [t.versionId, t.unit] })], +) + +export const refCompactionParts = sqliteTable( + 'ref_compaction_parts', + { + compactionId: text('compaction_id').notNull(), + part: integer('part').notNull(), + }, + (t) => [primaryKey({ columns: [t.compactionId, t.part] })], +) + +// --- Jobs (Node only; Cloudflare uses Queues) ---------------------------------------- + +export const jobs = sqliteTable( + 'jobs', + { + id: id(), + type: text('type').notNull(), + payload: json>('payload').notNull(), + status: text('status', { enum: ['queued', 'running', 'done', 'failed'] }) + .notNull() + .default('queued'), + attempts: integer('attempts').notNull().default(0), + runAt: ts('run_at').notNull(), + lockedUntil: ts('locked_until'), + error: text('error'), + createdAt: createdAt(), + }, + (t) => [index('jobs_ready_idx').on(t.status, t.runAt)], +) + +// --- Webhooks -------------------------------------------------------------------------- + +export const collectionWebhooks = sqliteTable( + 'collection_webhooks', + { + id: id(), + collectionId: text('collection_id') + .notNull() + .references(() => collections.id, { onDelete: 'cascade' }), + url: text('url').notNull(), + bumpFilter: json<('major' | 'minor' | 'patch')[]>('bump_filter') + .notNull() + .$defaultFn(() => ['major', 'minor', 'patch']), + secret: text('secret').notNull(), + enabled: bool('enabled').notNull().default(true), + createdBy: text('created_by'), + createdAt: createdAt(), + updatedAt: ts('updated_at') + .notNull() + .$defaultFn(() => new Date()), + lastDeliveryAt: ts('last_delivery_at'), + }, + (t) => [index('collection_webhooks_collection_idx').on(t.collectionId)], +) + +export const webhookDeliveries = sqliteTable( + 'webhook_deliveries', + { + id: id(), + webhookId: text('webhook_id') + .notNull() + .references(() => collectionWebhooks.id, { onDelete: 'cascade' }), + collectionId: text('collection_id').notNull(), + versionId: text('version_id'), + semver: text('semver'), + bumpType: text('bump_type', { enum: ['major', 'minor', 'patch'] }).notNull(), + event: text('event').notNull().default('version.created'), + payload: json>('payload').notNull(), + status: text('status', { enum: ['pending', 'success', 'failed'] }) + .notNull() + .default('pending'), + attempts: integer('attempts').notNull().default(0), + responseCode: integer('response_code'), + error: text('error'), + durationMs: integer('duration_ms'), + nextAttemptAt: ts('next_attempt_at'), + createdAt: createdAt(), + deliveredAt: ts('delivered_at'), + }, + (t) => [ + index('webhook_deliveries_webhook_idx').on(t.webhookId, t.createdAt), + index('webhook_deliveries_pending_idx').on(t.status, t.nextAttemptAt), + ], +) + +// --- ARKs, settings ------------------------------------------------------------------------ + +export const arkShoulders = sqliteTable('ark_shoulders', { + id: id(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + shoulder: text('shoulder').notNull().unique(), + createdAt: createdAt(), +}) + +export const arkCollections = sqliteTable('ark_collections', { + collectionId: text('collection_id') + .primaryKey() + .references(() => collections.id, { onDelete: 'cascade' }), + arkId: text('ark_id').notNull().unique(), + enabled: bool('enabled').notNull().default(true), + customUrl: text('custom_url'), + createdAt: createdAt(), +}) + +export const arkRecordTypes = sqliteTable( + 'ark_record_types', + { + collectionId: text('collection_id') + .notNull() + .references(() => collections.id, { onDelete: 'cascade' }), + recordType: text('record_type').notNull(), + redirectUrlField: text('redirect_url_field').notNull(), + }, + (t) => [primaryKey({ columns: [t.collectionId, t.recordType] })], +) + +export const instanceSettings = sqliteTable('instance_settings', { + key: text('key').primaryKey(), + value: json('value').notNull(), + updatedAt: ts('updated_at') + .notNull() + .$defaultFn(() => new Date()), +}) + +// --- Storage cleanup (planning: v2-storage-cleanup.md) ------------------------------- + +/** + * The write fence: one row. A write phase that may reuse objects already in the + * bucket (writers skip keys that exist) reads `epoch` before it writes, and the + * statement that makes its objects reachable succeeds only if the epoch is + * unchanged and no deletion window is open (cleanup/fence.ts). The sweep bumps + * the epoch when it opens a window and again when it closes it. + */ +export const storageFence = sqliteTable('storage_fence', { + id: integer('id').primaryKey(), + epoch: integer('epoch').notNull().default(0), + /** A deletion window is open until then; writers wait for it. */ + windowUntil: ts('window_until'), + windowRunId: text('window_run_id'), +}) + +export type CleanupStep = 'internal' | 'mark' | 'sweep' +export type CleanupStatus = 'queued' | 'running' | 'waiting' | 'done' | 'failed' + +/** Objects and bytes, deleted (or, in a dry run, that would be). */ +export interface CleanupCount { + objects: number + bytes: number +} + +export interface CleanupStats { + /** By kind: nodes, bodies, records, roots, private, files, collections, sessions, uploads, … */ + deleted: Record + /** Keys listed (sweep) or rows looked at (internal). */ + scanned: number + /** Listed keys of a shape the sweep doesn't delete. */ + unknown: number + /** Hashes in the mark (mark, and a sweep's re-marks). */ + marked: number + versions: number + collections: number + /** Database rows removed alongside (files, push runs). */ + rows: number + /** Deletion windows a sweep opened. */ + windows: number + /** Problems that didn't stop the run, first few. */ + problems: string[] + /** + * By kind, the first keys a sweep deleted (or, dry, would delete): a dry run + * can be checked before a real one. `collections/` lists each collection's + * prefix once. + */ + samples?: Record +} + +/** One run of a cleanup step: what the admin Cleanup page lists. */ +export const cleanupRuns = sqliteTable( + 'cleanup_runs', + { + id: id(), + step: text('step').$type().notNull(), + status: text('status').$type().notNull().default('queued'), + trigger: text('trigger', { enum: ['manual', 'schedule'] }).notNull(), + dryRun: bool('dry_run').notNull().default(false), + requestedBy: text('requested_by'), + /** A sweep's mark (a done mark run). */ + markRunId: text('mark_run_id'), + /** Progress carried between the run's jobs. */ + state: json>('state'), + /** The job that may run next; a duplicate or stale delivery carries another. */ + seq: integer('seq').notNull().default(0), + /** Held while a job of the run works, so a duplicate delivery waits. */ + lease: ts('lease'), + stats: json('stats'), + /** Why it stopped (failed), or what it is waiting for (waiting). */ + error: text('error'), + startedAt: ts('started_at'), + finishedAt: ts('finished_at'), + updatedAt: ts('updated_at'), + createdAt: createdAt(), + }, + (t) => [ + index('cleanup_runs_created_idx').on(t.createdAt), + index('cleanup_runs_step_idx').on(t.step, t.status), + ], +) diff --git a/packages/server/src/files/files.ts b/packages/server/src/files/files.ts new file mode 100644 index 0000000..d21065c --- /dev/null +++ b/packages/server/src/files/files.ts @@ -0,0 +1,436 @@ +/** + * Files (edge-redesign.md, "Files"): SHA-256-of-bytes ids, bytes never served + * from the app's origin (presigned URLs on the bucket's domain), uploads + * verified before a file exists. + * + * Upload paths: + * - small: PUT through the API, hashed in memory (bounded by SMALL_UPLOAD_BYTES); + * - direct: a presigned PUT (or multipart part URLs) to a staging key, then a + * job streams the object, checks its hash, and copies it to the canonical + * repository key `files/` (mirrors need canonical keys), then deletes + * the staging object. Abandoned ones go in storage cleanup (cleanup/internal.ts). + * + * Proof of possession: an upload always carries the bytes, even when the + * server already has the file, so "do you have X?" is never answered for free. + * + * Storing a file reuses the bytes when a `files` row exists, so the files row + * and the possession are written under the storage fence (cleanup/fence.ts): + * if the sweep deleted an unused copy meanwhile, the store runs again and puts + * the bytes back. + */ +import { createHash } from 'node:crypto' + +import { copyObject, fileTree, getEntry, RepoSource } from '@underlay/protocol' +import { and, eq, inArray, sql } from 'drizzle-orm' + +import { FenceError, fenced, fenceHolds, fenceMoved } from '../cleanup/fence.js' +import { chunks, inJson, JSON_CHUNK } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import { deniedHashes } from '../lib/limits.js' +import type { Db, Ports } from '../ports.js' + +/** PUT-through-the-API limit: the body is held in isolate memory to hash it. */ +export const SMALL_UPLOAD_BYTES = 32 * 1024 * 1024 +/** Single presigned PUT limit (S3/R2); larger files use multipart. */ +export const SINGLE_PUT_BYTES = 5 * 1024 * 1024 * 1024 +export const PART_BYTES = 100 * 1024 * 1024 +export const PRESIGN_SECONDS = 300 +/** The largest object R2 and S3 store, and the most parts a multipart upload has. */ +export const MAX_FILE_BYTES = 5 * 1024 ** 4 +export const MAX_PARTS = 10_000 +/** Part URLs presigned per response: the ticket has the first page, the client asks for the rest. */ +export const PARTS_PAGE = 100 + +/** A file's part size: PART_BYTES, or more (whole MiB) so it fits in MAX_PARTS parts. */ +export function partBytesFor(size: number): number { + const mib = 1024 * 1024 + return Math.max(PART_BYTES, Math.ceil(Math.ceil(size / MAX_PARTS) / mib) * mib) +} + +/** Presigned URLs for parts `from` … `from + PARTS_PAGE - 1` of a multipart upload. */ +export async function presignParts( + ports: Ports, + upload: { storageKey: string; multipartUploadId: string | null; size: number }, + from: number, +): Promise<{ partNumber: number; url: string }[]> { + if (!upload.multipartUploadId) return [] + const count = Math.ceil(upload.size / partBytesFor(upload.size)) + const last = Math.min(count, from + PARTS_PAGE - 1) + const presigner = ports.stores.fileBytes.presigner + const parts: { partNumber: number; url: string }[] = [] + for (let n = Math.max(1, from); n <= last; n++) { + parts.push({ + partNumber: n, + url: await presigner.presignPart(upload.storageKey, upload.multipartUploadId, n, 3600), + }) + } + return parts +} + +// Types that render or run in a browser are stored as inert bytes (v1 rule). +const UNSAFE_MIME = /^(text\/html|application\/xhtml|image\/svg|text\/xml|application\/xml)/i +export const safeMimeType = (mime: string | undefined) => + !mime || UNSAFE_MIME.test(mime.trim()) ? 'application/octet-stream' : mime.trim() + +const HEX64 = /^[0-9a-f]{64}$/ +export const cleanHash = (h: string) => h.replace(/^sha256:/, '').toLowerCase() +export const isHash = (h: string) => HEX64.test(h) + +/** + * May this caller read this file through this collection? Non-members: the file + * is in the collection's cumulative public files tree. Members, who read every + * set: any file the collection holds, the same "held" a commit uses + * (versions/file-refs.ts collectionFileSizes): also the head's file trees (both + * sets), or bytes uploaded and verified under this collection. + */ +export async function canReadFile( + ports: Ports, + collection: typeof schema.collections.$inferSelect, + member: boolean, + hash: string, +): Promise { + return (await readableFiles(ports, collection, member, [hash])).has(hash) +} + +/** canReadFile for many hashes, with the head's trees looked up once. */ +export async function readableFiles( + ports: Ports, + collection: typeof schema.collections.$inferSelect, + member: boolean, + hashes: string[], +): Promise> { + const repo = await ports.stores.forCollection(collection.id) + const source = new RepoSource(fileTree, repo) + const roots = [collection.publicFilesRoot] + if (member && collection.headVersionId) { + const [v] = await ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, collection.headVersionId)) + if (v) { + const root = await repo.root(v.hash) + roots.push(root.public.files.root) + if (root.private) roots.push((await repo.privateSet(root.private)).files.root) + } + } + const out = new Set() + const rest: string[] = [] + for (const h of hashes) { + let found = false + for (const r of roots) { + if (r && (await getEntry(source, r, h))) { + found = true + break + } + } + if (found) out.add(h) + else rest.push(h) + } + if (member) + for (const h of (await uploadedFiles(ports.db, collection.id, rest)).keys()) out.add(h) + return out +} + +/** + * Of these hashes, the files whose bytes were uploaded and verified under this + * collection (a `file_uploads` row), with their sizes: a query per JSON_CHUNK. + * Together with the collection's file trees, what the collection holds. + */ +export async function uploadedFiles( + db: Db, + collectionId: string, + hashes: string[], +): Promise> { + const out = new Map() + for (const part of chunks([...new Set(hashes)], JSON_CHUNK)) { + const rows = await db + .select({ hash: schema.files.hash, size: schema.files.size }) + .from(schema.files) + .innerJoin(schema.fileUploads, eq(schema.fileUploads.hash, schema.files.hash)) + .where( + and( + inJson(schema.files.hash, part), + eq(schema.fileUploads.collectionId, collectionId), + eq(schema.fileUploads.status, 'verified'), + ), + ) + for (const r of rows) out.set(r.hash, r.size) + } + return out +} + +/** + * A filesystem store's `/_blob/…` response (Node entry), made inert: those + * bytes come from the app's own origin, so an uploaded HTML or SVG file must + * neither render nor run there. Always an attachment, sandboxed, never sniffed. + */ +export function inertBlobResponse(res: Response): Response { + const out = new Response(res.body, res) + out.headers.set('content-security-policy', 'sandbox') + out.headers.set('x-content-type-options', 'nosniff') + const disposition = out.headers.get('content-disposition') + if (!disposition || !/^\s*attachment\b/i.test(disposition)) + out.headers.set('content-disposition', 'attachment') + return out +} + +export async function presignDownload(ports: Ports, hash: string): Promise { + return (await presignDownloads(ports, [hash])).get(hash) ?? null +} + +/** Presigned download URLs for stored files, with the file rows read in chunks. */ +export async function presignDownloads( + ports: Ports, + hashes: string[], +): Promise> { + const out = new Map() + // Blocked files get no URL, whatever route asked (lib/limits.ts). + const denied = await deniedHashes(ports.db) + for (const part of chunks([...new Set(hashes)].filter((h) => !denied.has(h)))) { + const rows = await ports.db.select().from(schema.files).where(inArray(schema.files.hash, part)) + for (const f of rows) { + // Downloads are attachments: nothing renders on the bucket's domain either. + out.set( + f.hash, + await ports.stores.fileBytes.presigner.presignGet(f.storageKey, { + expiresIn: PRESIGN_SECONDS, + disposition: `attachment; filename="${f.hash}"`, + contentType: f.mimeType, + }), + ) + } + } + return out +} + +/** + * Store verified bytes at the canonical key and record the file, and that this + * collection proved it holds them (a commit may then reference the file). + * Idempotent. + */ +export async function storeSmallFile( + ports: Ports, + collectionId: string, + hash: string, + bytes: Uint8Array, + mime: string, +): Promise<'stored' | 'mismatch'> { + const actual = createHash('sha256').update(bytes).digest('hex') + if (actual !== hash) return 'mismatch' + await fenced(ports.db, async (fence) => { + const [existing] = await ports.db + .select() + .from(schema.files) + .where(eq(schema.files.hash, hash)) + .limit(1) + const key = existing?.storageKey ?? ports.stores.canonicalFileKey(hash) + if (!existing) { + await ports.stores.fileBytes.put(key, bytes, { contentType: mime, ifAbsent: true }) + await recordFile( + ports.db, + { hash, size: bytes.byteLength, mimeType: mime, storageKey: key }, + fence, + ) + } + const [proved] = await ports.db + .select({ id: schema.fileUploads.id }) + .from(schema.fileUploads) + .where( + and( + eq(schema.fileUploads.collectionId, collectionId), + eq(schema.fileUploads.hash, hash), + eq(schema.fileUploads.status, 'verified'), + ), + ) + .limit(1) + if (!proved) { + const lit = (v: unknown) => sql`${v}` + await ports.db.insert(schema.fileUploads).select( + ports.db + .select({ + id: lit(crypto.randomUUID()).as('id'), + collectionId: lit(collectionId).as('collection_id'), + sessionId: lit(null).as('session_id'), + hash: lit(hash).as('hash'), + size: lit(bytes.byteLength).as('size'), + mimeType: lit(mime).as('mime_type'), + storageKey: lit(key).as('storage_key'), + multipartUploadId: lit(null).as('multipart_upload_id'), + status: lit('verified').as('status'), + error: lit(null).as('error'), + createdAt: lit(Date.now()).as('created_at'), + }) + .from(schema.storageFence) + .where(fenceHolds(fence)) as never, + ) + if (await fenceMoved(ports.db, fence)) throw new FenceError() + } + }) + return 'stored' +} + +/** + * Insert a `files` row (if there is none) only while the storage fence still + * holds; FenceError when it doesn't, so the caller redoes its write. + */ +export async function recordFile( + db: Db, + f: { hash: string; size: number; mimeType: string; storageKey: string }, + fence: number, +): Promise { + const lit = (v: unknown) => sql`${v}` + const now = Date.now() + await db + .insert(schema.files) + .select( + db + .select({ + hash: lit(f.hash).as('hash'), + size: lit(f.size).as('size'), + mimeType: lit(f.mimeType).as('mime_type'), + storageKey: lit(f.storageKey).as('storage_key'), + verifiedAt: lit(now).as('verified_at'), + createdAt: lit(now).as('created_at'), + }) + .from(schema.storageFence) + .where(fenceHolds(fence)) as never, + ) + .onConflictDoNothing() + if (await fenceMoved(db, fence)) throw new FenceError() +} + +export interface UploadTicket { + id: string + url?: string + /** Multipart: every part but the last is partBytes long. */ + partBytes?: number + partCount?: number + /** The first PARTS_PAGE parts; GET …/uploads/:id/parts?from=n for the rest. */ + parts?: { partNumber: number; url: string }[] + expiresIn: number +} + +/** Start a direct upload to a staging key; returns where the client PUTs bytes. */ +export async function startUpload( + ports: Ports, + collectionId: string, + req: { hash: string; size: number; mimeType: string }, +): Promise { + const id = crypto.randomUUID() + const key = ports.stores.stagingKey(id) + const blobs = ports.stores.fileBytes + let multipartUploadId: string | null = null + const ticket: UploadTicket = { id, expiresIn: 3600 } + if (req.size <= SINGLE_PUT_BYTES) { + ticket.url = await blobs.presigner.presignPut(key, { expiresIn: 3600 }) + } else { + multipartUploadId = await blobs.presigner.createMultipart(key, req.mimeType) + ticket.partBytes = partBytesFor(req.size) + ticket.partCount = Math.ceil(req.size / ticket.partBytes) + ticket.parts = await presignParts( + ports, + { storageKey: key, multipartUploadId, size: req.size }, + 1, + ) + } + await ports.db.insert(schema.fileUploads).values({ + id, + collectionId, + hash: req.hash, + size: req.size, + mimeType: req.mimeType, + storageKey: key, + multipartUploadId, + }) + return ticket +} + +export async function completeUpload( + ports: Ports, + upload: typeof schema.fileUploads.$inferSelect, + parts: { partNumber: number; etag: string }[] | undefined, +): Promise { + if (upload.multipartUploadId) { + if (!parts?.length) throw new Error('Multipart uploads complete with their parts') + await ports.stores.fileBytes.presigner.completeMultipart( + upload.storageKey, + upload.multipartUploadId, + parts, + ) + } + const claimed = await ports.db + .update(schema.fileUploads) + .set({ status: 'verifying' }) + .where(and(eq(schema.fileUploads.id, upload.id), eq(schema.fileUploads.status, 'pending'))) + .returning({ id: schema.fileUploads.id }) + if (claimed.length === 1) await ports.jobs.enqueue({ type: 'files.verify', uploadId: upload.id }) +} + +/** + * Verify a staged upload: stream it, hash it, and on a match make it a file. + * One job hashes at roughly 1 GB/s of CPU; files past what fits in one job's + * CPU budget need a resumable SHA-256 (edge-redesign.md, Files). + */ +export async function verifyUpload(ports: Ports, uploadId: string): Promise { + const [u] = await ports.db + .select() + .from(schema.fileUploads) + .where(eq(schema.fileUploads.id, uploadId)) + .limit(1) + if (!u || u.status !== 'verifying') return + const blobs = ports.stores.fileBytes + const fail = async (error: string) => { + await ports.db + .update(schema.fileUploads) + .set({ status: 'failed', error }) + .where(eq(schema.fileUploads.id, uploadId)) + await blobs.delete(u.storageKey) + } + const obj = await blobs.get(u.storageKey) + if (!obj) return fail('Nothing was uploaded') + const h = createHash('sha256') + let size = 0 + const reader = obj.body.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + h.update(value) + size += value.byteLength + } + const actual = h.digest('hex') + if (actual !== u.hash) return fail(`Hash mismatch: uploaded bytes hash to ${actual}`) + if (size !== u.size) return fail(`Size mismatch: ${size} bytes, declared ${u.size}`) + + // The write phase: from here a deletion window means doing it again (the + // staging object stays until the upload is verified, so it can). + const verified = await fenced(ports.db, async (fence) => { + const [existing] = await ports.db + .select() + .from(schema.files) + .where(eq(schema.files.hash, u.hash)) + .limit(1) + if (!existing) { + const storageKey = ports.stores.canonicalFileKey(u.hash) + // With the size, a store copies past CopyObject's 5 GiB in parts (UploadPartCopy). + await copyObject(blobs, u.storageKey, storageKey, size) + await recordFile(ports.db, { hash: u.hash, size, mimeType: u.mimeType, storageKey }, fence) + } + const done = await ports.db + .update(schema.fileUploads) + .set({ status: 'verified' }) + .where( + and( + eq(schema.fileUploads.id, uploadId), + eq(schema.fileUploads.status, 'verifying'), + fenceHolds(fence), + ), + ) + .returning({ id: schema.fileUploads.id }) + if (done.length === 0 && (await fenceMoved(ports.db, fence))) throw new FenceError() + return done.length === 1 + }) + if (verified) await blobs.delete(u.storageKey) +} + +registerJob('files.verify', async (job, ports) => verifyUpload(ports, String(job.uploadId))) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts new file mode 100644 index 0000000..f20b255 --- /dev/null +++ b/packages/server/src/handlers.ts @@ -0,0 +1,182 @@ +/** + * Job handlers. Imported by every entry so the registry is populated. + * + * version.published after a CAS publish: webhooks, mirror sync and the + * reference log hang off this (phases 4, 7, 11) + * repo.repairLog rewrite a collection's version log entries that a + * commit failed to write after publishing + * push.compact merge a full tier of a session's runs (push/compact.ts) + * commit.unit, a parallel commit's key-range units and its assembly + * commit.assemble (push/parallel.ts) + * mirror.version copy the next version to a bucket mirror (locations/mirror.ts); + * queued after each publish, and by the sweep for laggards + * maintenance.sweep the cron's housekeeping (every 10 minutes), storage cleanup included + * cleanup.* storage cleanup runs (cleanup/runs.ts) + * collection.info* rewrite collection.json after a change outside a version + * (versions/collection-info.ts) + */ +import { + type BumpType, + type FsckCursor, + type FsckReport, + fsckStep, + readCollectionInfo, + readHead, +} from '@underlay/protocol' +import { and, asc, eq, gt } from 'drizzle-orm' + +import './files/files.js' +import { reconcileDue } from './billing/reconcile.js' +import { cleanupTick } from './cleanup/runs.js' +import * as schema from './db/schema.js' +import './push/compact.js' +import './push/parallel.js' +import './refs/log.js' +import { registerJob } from './jobs.js' +import { recheckLocations } from './locations/locations.js' +import { laggingPlacements, queueMirrors } from './locations/mirror.js' +import type { Ports } from './ports.js' +import { expireSessions } from './push/finalize.js' +import { queueInfoBackfill } from './versions/collection-info.js' +import { appendVersionLog } from './versions/commit.js' +import { enqueueDeliveries, purgeOldDeliveries } from './webhooks/webhooks.js' + +registerJob('version.published', async (job, ports) => { + await enqueueDeliveries(ports, String(job.versionId), job.bump as BumpType) + await ports.jobs.enqueue({ type: 'refs.index', versionId: String(job.versionId) }) + const [v] = await ports.db + .select({ collectionId: schema.versions.collectionId }) + .from(schema.versions) + .where(eq(schema.versions.id, String(job.versionId))) + if (v) await queueMirrors(ports, v.collectionId) +}) + +registerJob('maintenance.sweep', async (_job, ports) => { + await expireSessions(ports) + await purgeOldDeliveries(ports) + // Weekly counter reconciliation, a few collections at a time. + await reconcileDue(ports) + // Customer storage locations, re-checked daily. + await recheckLocations(ports) + // Yesterday's usage rollups, rebuilt once from the log (a retried batch counts once). + await rebuildYesterday(ports) + // Rewrite collection.json files earlier code wrote into the current form, once. + await queueInfoBackfill(ports) + // Mirrors that fell behind (a failed copy, a missed job) catch up. + const lagging = await laggingPlacements(ports) + await ports.jobs.enqueueBatch( + lagging.map((placementId) => ({ type: 'mirror.version', placementId })), + ) + // Storage cleanup: finished sessions and abandoned uploads, stalled runs, the weekly run. + // Last, and on its own: a failure here shouldn't rerun the rest. + await cleanupTick(ports).catch((err) => console.error('[cleanup] tick failed:', err)) +}) + +/** Queue the rebuild of yesterday's usage rollups, once (a marker in the internal area). */ +async function rebuildYesterday(ports: Ports) { + const day = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + const marker = `usage-rebuild/queued-${day}` + if (await ports.stores.internal.head(marker)) return + await ports.stores.internal.put(marker, day) + await ports.jobs.enqueue({ type: 'usage.rebuild', day, step: 0 }) +} + +/** + * fsck a collection's primary repository (protocol fsckStep): the log under this + * deployment's key and the keys collection.json declares, then every version + * against the one before, so each job checks a bounded number of changes. The + * report goes to the internal area, fsck/.json, and grows as the + * job chain runs (`running` until the last step). + */ +export const fsckConfig = { changesPerJob: 1_000_000 } + +interface FsckState extends FsckReport { + running: boolean + step: number + cursor: FsckCursor | null + fileBytes: boolean + startedAt: string + checkedAt: string | null +} + +const fsckKey = (collectionId: string) => `fsck/${collectionId}.json` + +async function saveFsck(ports: Ports, collectionId: string, state: FsckState) { + await ports.stores.internal.put(fsckKey(collectionId), JSON.stringify(state), { + contentType: 'application/json', + }) +} + +registerJob('repo.fsck', async (job, ports) => { + const collectionId = String(job.collectionId) + await saveFsck(ports, collectionId, { + ok: true, + errors: [], + moreErrors: 0, + versions: 0, + trees: 0, + nodes: 0, + leaves: 0, + records: 0, + files: 0, + log: 'trusted keys', + running: true, + step: 0, + cursor: null, + fileBytes: job.fileBytes === true, + startedAt: new Date().toISOString(), + checkedAt: null, + }) + await ports.jobs.enqueue({ type: 'repo.fsckStep', collectionId, step: 0 }) +}) + +registerJob('repo.fsckStep', async (job, ports) => { + const collectionId = String(job.collectionId) + const obj = await ports.stores.internal.get(fsckKey(collectionId)) + const state = obj ? (JSON.parse(await obj.text()) as FsckState) : null + if (!state?.running || state.step !== Number(job.step)) return // finished, or a duplicate + const repo = await ports.stores.forCollection(collectionId) + const own = (await ports.signer()).publicKey + const info = await readCollectionInfo(repo, collectionId) + const { report, cursor } = await fsckStep(repo, { + collectionId, + trustedKeys: [own, ...(info?.keys ?? []).filter((k) => k.id !== own.id)], + fileBytes: state.fileBytes, + cursor: state.cursor, + changeBudget: fsckConfig.changesPerJob, + }) + const room = Math.max(0, 100 - state.errors.length) + const next: FsckState = { + ...state, + ok: state.ok && report.ok, + errors: [...state.errors, ...report.errors.slice(0, room)], + moreErrors: state.moreErrors + report.moreErrors + Math.max(0, report.errors.length - room), + versions: state.versions + report.versions, + trees: state.trees + report.trees, + nodes: state.nodes + report.nodes, + leaves: state.leaves + report.leaves, + records: state.records + report.records, + files: state.files + report.files, + step: state.step + 1, + cursor, + running: cursor !== null, + checkedAt: cursor ? null : new Date().toISOString(), + } + await saveFsck(ports, collectionId, next) + if (cursor) await ports.jobs.enqueue({ type: 'repo.fsckStep', collectionId, step: next.step }) + else if (!next.ok) console.error(`[fsck] ${collectionId}:`, next.errors.slice(0, 10)) +}) + +registerJob('repo.repairLog', async (job, ports) => { + const collectionId = String(job.collectionId) + const repo = await ports.stores.forCollection(collectionId) + const head = await readHead(repo, collectionId) + const missing = await ports.db + .select() + .from(schema.versions) + .where( + and(eq(schema.versions.collectionId, collectionId), gt(schema.versions.seq, head?.seq ?? 0)), + ) + .orderBy(asc(schema.versions.seq)) + for (const v of missing) await appendVersionLog(ports, repo, collectionId, v) +}) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..0e20ab4 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,15 @@ +/** What other packages (migrate, web's smoke test) use from the server. */ +export { MemoryCache } from './cache.js' +export { openNodeDb } from './db/node.js' +export * as dbSchema from './db/schema.js' +export { drainSqliteJobs, SqliteJobs } from './jobs.js' +export type { Ports } from './ports.js' +export { createStores } from './stores.js' +export { publishCollectionInfo } from './versions/collection-info.js' +export { + appendVersionLog, + type BaseVersion, + commitVersion, + type TypeInput, +} from './versions/commit.js' +import './handlers.js' diff --git a/packages/server/src/jobs.ts b/packages/server/src/jobs.ts new file mode 100644 index 0000000..d80660e --- /dev/null +++ b/packages/server/src/jobs.ts @@ -0,0 +1,187 @@ +/** + * Background work. Handlers are idempotent: a job may run more than once + * (Queues delivers at least once; the Node runner retries after a crash), so + * every handler either writes content-addressed objects or makes a conditional + * state change. + * + * Cloudflare: Cloudflare Queues; the Worker's `queue` handler calls runJob. + * Node: a `jobs` table polled by an in-process runner. + * + * Two kinds (v2-scale-review.md S1, S2): bulk jobs can run for minutes or walk + * a whole collection, and go to their own queue, one per invocation; the rest + * are small and interactive (a webhook, a file check), and run several at a + * time so they never wait behind a bulk push or a mirror backfill. + */ +import { and, asc, eq, lte, or, sql } from 'drizzle-orm' + +import * as schema from './db/schema.js' +import type { Db, JobMessage, Jobs, Ports } from './ports.js' + +export type JobHandler = (job: JobMessage, ports: Ports) => Promise + +/** Job types that go to the bulk queue. Everything else is interactive. */ +export const BULK_JOBS: ReadonlySet = new Set([ + 'push.commit', // async commits: 100k records and up + 'commit.unit', + 'commit.assemble', + 'push.compact', + 'refs.index', + 'refs.indexUnit', + 'refs.compact', + 'refs.compactPart', + 'refs.finishCompaction', + 'mirror.version', + 'repo.repairLog', + 'reconcile.collection', + 'reconcile.step', + 'reconcile.version', + 'reconcile.finish', + 'usage.rebuild', + 'repo.fsck', + 'repo.fsckStep', + // Hashes a whole file: four at once could blow an interactive batch's CPU. + 'files.verify', + 'maintenance.sweep', + // Storage cleanup: a mark walks every tree on the platform, a sweep lists the bucket. + 'cleanup.internal', + 'cleanup.mark', + 'cleanup.sweep', +]) + +export const isBulk = (type: string) => BULK_JOBS.has(type) + +const handlers = new Map() + +export function registerJob(type: string, handler: JobHandler): void { + handlers.set(type, handler) +} + +export async function runJob(job: JobMessage, ports: Ports): Promise { + const handler = handlers.get(job.type) + if (!handler) throw new Error(`No handler for job type ${job.type}`) + await handler(job, ports) +} + +// --- Cloudflare Queues -------------------------------------------------------------- + +interface CfQueue { + send(body: unknown, opts?: { delaySeconds?: number }): Promise + sendBatch(messages: { body: unknown; delaySeconds?: number }[]): Promise +} + +/** Jobs on Cloudflare Queues: interactive and bulk queues (one queue for both if no bulk is bound). */ +export class QueueJobs implements Jobs { + constructor( + readonly queue: CfQueue, + readonly bulk: CfQueue = queue, + ) {} + #for(type: string) { + return isBulk(type) ? this.bulk : this.queue + } + async enqueue(job: JobMessage, opts?: { delaySeconds?: number }) { + await this.#for(job.type).send( + job, + opts?.delaySeconds ? { delaySeconds: opts.delaySeconds } : undefined, + ) + } + async enqueueBatch(jobs: JobMessage[]) { + for (const q of [this.queue, this.bulk]) { + const mine = jobs.filter((j) => this.#for(j.type) === q) + // sendBatch takes at most 100 messages. + for (let i = 0; i < mine.length; i += 100) { + await q.sendBatch(mine.slice(i, i + 100).map((body) => ({ body }))) + } + if (this.bulk === this.queue) break + } + } +} + +// --- Node: SQLite jobs table ----------------------------------------------------------- + +const LOCK_MS = 15 * 60 * 1000 +const MAX_ATTEMPTS = 8 + +export class SqliteJobs implements Jobs { + constructor(readonly db: Db) {} + async enqueue(job: JobMessage, opts?: { delaySeconds?: number }) { + await this.db.insert(schema.jobs).values({ + type: job.type, + payload: job, + runAt: new Date(Date.now() + (opts?.delaySeconds ?? 0) * 1000), + }) + } + async enqueueBatch(jobs: JobMessage[]) { + // D1-safe chunking of bound parameters (6 per row, defaults included). + for (let i = 0; i < jobs.length; i += 15) { + await this.db + .insert(schema.jobs) + .values( + jobs.slice(i, i + 15).map((job) => ({ type: job.type, payload: job, runAt: new Date() })), + ) + } + } +} + +/** Claim one ready job, atomically: a single UPDATE … RETURNING. */ +async function claim(db: Db): Promise { + const now = Date.now() + const ready = db + .select({ id: schema.jobs.id }) + .from(schema.jobs) + .where( + or( + and(eq(schema.jobs.status, 'queued'), lte(schema.jobs.runAt, new Date(now))), + and(eq(schema.jobs.status, 'running'), lte(schema.jobs.lockedUntil, new Date(now))), + ), + ) + // Interactive jobs first, as their own queue would run them. + .orderBy( + sql`CASE WHEN ${schema.jobs.type} IN (${sql.join( + [...BULK_JOBS].map((t) => sql`${t}`), + sql`, `, + )}) THEN 1 ELSE 0 END`, + asc(schema.jobs.runAt), + ) + .limit(1) + const [job] = await db + .update(schema.jobs) + .set({ + status: 'running', + lockedUntil: new Date(now + LOCK_MS), + attempts: sql`${schema.jobs.attempts} + 1`, + }) + .where(eq(schema.jobs.id, sql`(${ready})`)) + .returning() + return job ?? null +} + +/** + * Run ready jobs until none are left. Returns how many ran. The Node entry calls + * this on an interval and right after enqueueing; tests call it directly. + */ +export async function drainSqliteJobs(ports: Ports, opts: { max?: number } = {}): Promise { + let ran = 0 + for (;;) { + if (opts.max !== undefined && ran >= opts.max) return ran + const job = await claim(ports.db) + if (!job) return ran + ran++ + try { + await runJob(job.payload as JobMessage, ports) + await ports.db.delete(schema.jobs).where(eq(schema.jobs.id, job.id)) + } catch (err) { + const failed = job.attempts >= MAX_ATTEMPTS + const backoff = Math.min(3600, 2 ** job.attempts) * 1000 + await ports.db + .update(schema.jobs) + .set({ + status: failed ? 'failed' : 'queued', + runAt: new Date(Date.now() + backoff), + lockedUntil: null, + error: String((err as Error)?.stack ?? err).slice(0, 4000), + }) + .where(eq(schema.jobs.id, job.id)) + console.error(`[jobs] ${job.type} attempt ${job.attempts} failed:`, err) + } + } +} diff --git a/src/lib/ark.ts b/packages/server/src/lib/ark.ts similarity index 79% rename from src/lib/ark.ts rename to packages/server/src/lib/ark.ts index a07522d..f4f8d70 100644 --- a/src/lib/ark.ts +++ b/packages/server/src/lib/ark.ts @@ -1,10 +1,16 @@ +/** + * ARK (Archival Resource Key) helpers: minting ids, check characters, parsing + * and building ARK URLs, and ERC text. Pure, so they behave the same on Node + * and Workers; the database side (shoulders, settings, resolution) is in + * api/ark.ts. Ported from v1 unchanged in behaviour: existing ARKs must keep + * resolving. + */ import { createHash } from 'node:crypto' -import { eq, sql } from 'drizzle-orm' - -import { db, schema } from '../db/client.server.js' - -export const DEFAULT_NAAN = process.env.ARK_DEFAULT_NAAN ?? '12345' +// Workers only have process.env under nodejs_compat; don't crash without it. +export const DEFAULT_NAAN = globalThis.process?.env?.ARK_DEFAULT_NAAN ?? '12345' +// ARKs are persistent identifiers, so they always name the canonical site, +// whichever deployment minted or serves them. const SITE_URL = 'https://underlay.org' // Betanumeric: consonants (no 'l') + digits @@ -28,7 +34,7 @@ export function computeNcdaCheckChar(name: string): string { // so the primordinal shoulder parsing is always unambiguous. export function collectionToArkId(collectionId: string): string { const hash = createHash('sha256').update(collectionId).digest() - let n = BigInt('0x' + hash.slice(0, 8).toString('hex')) + let n = BigInt('0x' + hash.subarray(0, 8).toString('hex')) const base = BigInt(BETANUMERIC.length) const chars: string[] = [] for (let i = 0; i < ARK_ID_LENGTH; i++) { @@ -56,32 +62,6 @@ export function nextShoulderCounter(count: number): string { return result } -export async function getOrMintShoulder(organizationId: string): Promise { - const [existing] = await db - .select({ shoulder: schema.arkShoulders.shoulder }) - .from(schema.arkShoulders) - .where(eq(schema.arkShoulders.organizationId, organizationId)) - .limit(1) - if (existing) return existing.shoulder - - for (let attempt = 0; attempt < 10; attempt++) { - const [countRow] = await db - .select({ count: sql`count(*)::int` }) - .from(schema.arkShoulders) - const counter = nextShoulderCounter(countRow?.count ?? 0) - const digit = Math.floor(Math.random() * 10).toString() - const shoulder = `ul${counter}${digit}` - try { - await db.insert(schema.arkShoulders).values({ organizationId, shoulder }) - return shoulder - } catch (e: any) { - // Retry on unique constraint violation (concurrent insert) - if (e?.cause?.code !== '23505' && e?.code !== '23505') throw e - } - } - throw new Error('Failed to mint ARK shoulder after 10 attempts') -} - export interface ArkComponents { shoulder: string collectionArkId: string diff --git a/packages/server/src/lib/avatars.ts b/packages/server/src/lib/avatars.ts new file mode 100644 index 0000000..e66abce --- /dev/null +++ b/packages/server/src/lib/avatars.ts @@ -0,0 +1,73 @@ +/** + * Org logos in the deployment's public assets bucket: what an upload may be, + * and which stored objects this deployment may delete. + * + * Objects are content-addressed (`avatars//.`) and immutable. + * A logo is deleted only when it's in this deployment's bucket: a URL under + * ASSETS_BASE_URL and the org's own folder. Migrated URLs on another host + * (staging's v1 links into production's bucket) are never touched. + */ +import { listAll } from '@underlay/protocol' + +import type { PublicAssets } from '../ports.js' + +/** + * The raster format of an image by its magic bytes, or null. SVG is never one: + * the assets domain is underlay.org's, and an SVG there could run script. + */ +export function sniffRaster(b: Uint8Array): { type: string; ext: string } | null { + const at = (offset: number, bytes: number[]) => bytes.every((v, i) => b[offset + i] === v) + const ascii = (offset: number, s: string) => + at( + offset, + [...s].map((ch) => ch.charCodeAt(0)), + ) + if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + return { type: 'image/png', ext: 'png' } + if (at(0, [0xff, 0xd8, 0xff])) return { type: 'image/jpeg', ext: 'jpg' } + if (ascii(0, 'GIF87a') || ascii(0, 'GIF89a')) return { type: 'image/gif', ext: 'gif' } + if (ascii(0, 'RIFF') && ascii(8, 'WEBP')) return { type: 'image/webp', ext: 'webp' } + return null +} + +/** The org's folder in the bucket. */ +export const avatarFolder = (orgId: string) => `avatars/${orgId}/` + +/** + * Delete a logo this deployment stored for this org. Anything else (another + * host, another org's folder, an unexpected key) is left alone. Non-fatal: an + * orphaned logo is harmless, so storage errors are logged, not thrown. + */ +export async function deleteOwnedAvatar( + assets: PublicAssets, + orgId: string, + url: string | null, +): Promise { + const folder = `${assets.baseUrl}/${avatarFolder(orgId)}` + if (!url?.startsWith(folder)) return + const name = url.slice(folder.length) + if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/.test(name)) return + try { + await assets.store.delete(avatarFolder(orgId) + name) + } catch (err) { + console.error(`[avatars] Failed to delete ${url}:`, err) + } +} + +/** + * Delete every logo under the org's folder in this deployment's bucket, as v1 + * did when an org or account was deleted. Non-fatal, as above. + */ +export async function deleteOrgAvatars( + assets: PublicAssets | undefined, + orgId: string, +): Promise { + if (!assets) return + try { + for await (const key of listAll(assets.store, avatarFolder(orgId))) { + await assets.store.delete(key) + } + } catch (err) { + console.error(`[avatars] Failed to delete logos of org ${orgId}:`, err) + } +} diff --git a/packages/server/src/lib/limits.ts b/packages/server/src/lib/limits.ts new file mode 100644 index 0000000..7d7cc63 --- /dev/null +++ b/packages/server/src/lib/limits.ts @@ -0,0 +1,124 @@ +import * as schema from '../db/schema.js' +/** + * Abuse controls (edge-redesign.md, "Files"; v2-alignment-review.md, "Abuse + * controls"): request budgets, and the hash denylist. + * + * Budgets are v1's for the API: 60 units a minute per IP when anonymous, 5,000 + * per user. Anonymous pages (ARK resolution and the auth routes included) have + * their own per-IP budget, 600 a minute, since many readers can share an + * address. Every request the Worker serves spends from one of them; a page's own + * in-process API calls are part of its cost. Expensive requests cost more (requestCost). On + * Workers they're the rate-limit binding (per location, approximate); on Node + * a fixed window in memory. + * + * The denylist is a table of file and record hashes that are never served: + * file redirects and presigns, record reads and listings. It is small and + * read on most requests, so each isolate holds a copy for a minute. + */ +import type { Db, RateLimiter } from '../ports.js' + +export const LIMITS = { anon: 60, user: 5_000, page: 600 } as const +const WINDOW_MS = 60_000 + +/** A fixed-window limiter in memory (Node, one process). */ +export function memoryRateLimiter( + limits: { anon: number; user: number; page: number } = LIMITS, +): RateLimiter { + const windows = new Map() + let sweptAt = Date.now() + return { + async check(kind, key, cost = 1) { + const now = Date.now() + if (now - sweptAt > WINDOW_MS) { + for (const [k, w] of windows) if (w.resetAt < now) windows.delete(k) + sweptAt = now + } + const id = `${kind}:${key}` + let w = windows.get(id) + if (!w || w.resetAt < now) windows.set(id, (w = { count: 0, resetAt: now + WINDOW_MS })) + w.count += cost + return w.count <= limits[kind] + }, + } +} + +/** The binding's shape (wrangler.jsonc "ratelimits"). */ +export interface RateLimitBinding { + limit(options: { key: string }): Promise<{ success: boolean }> +} + +/** Cloudflare's rate-limit bindings, one per kind (pages fall back to the API's anonymous one). */ +export function bindingRateLimiter(b: { + anon: RateLimitBinding + user: RateLimitBinding + page?: RateLimitBinding | undefined +}): RateLimiter { + return { + // The binding counts one per call: a request costing n calls it n times. + async check(kind, key, cost = 1) { + const binding = b[kind] ?? b.anon + for (let i = 0; i < cost; i++) if (!(await binding.limit({ key })).success) return false + return true + }, + } +} + +/** What a request costs from its budget: whole-version reads cost most, pages more than a call. */ +const COSTS: [RegExp, number][] = [ + [/^\/api\/collections\/[^/]+\/[^/]+\/export$/, 20], + [/\/versions\/[^/]+\/(pack|records\.ndjson(\.gz)?)$/, 10], + [/^\/api\/records\/batch$/, 5], + [/^\/api\/records\/[^/]+\/(provenance|history)$/, 5], + [/\/(diff|history)$/, 5], + // Pages: a record page reads its provenance; any page makes several calls. + [/^\/records\//, 5], + [/^\/(?!api\/)/, 2], +] + +export function requestCost(path: string): number { + for (const [re, cost] of COSTS) if (re.test(path)) return cost + return 1 +} + +/** + * The client's address for anonymous budgets. cf-connecting-ip is set by + * Cloudflare and can't be forged through it; otherwise the rightmost + * X-Forwarded-For hop is what the trusted proxy saw (the leftmost is the + * client's to write). + */ +export function clientIp(headers: Headers): string { + const cf = headers.get('cf-connecting-ip') + if (cf) return cf.trim() + const hops = (headers.get('x-forwarded-for') ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + return hops.at(-1) ?? 'unknown' +} + +const DENY_TTL_MS = 60_000 +const denyCache = new WeakMap> }>() + +/** Blocked hashes (files and records), cached per database for a minute. */ +export function deniedHashes(db: Db): Promise> { + const hit = denyCache.get(db) + if (hit && Date.now() - hit.at < DENY_TTL_MS) return hit.hashes + const hashes = db + .select({ hash: schema.denylist.hash }) + .from(schema.denylist) + .then((rows) => new Set(rows.map((r) => r.hash))) + // A failed read isn't cached. + hashes.catch(() => denyCache.delete(db)) + denyCache.set(db, { at: Date.now(), hashes }) + return hashes +} + +/** Forget the cached denylist (after this isolate changes it). */ +export function forgetDenylist(db: Db): void { + denyCache.delete(db) +} + +/** Whether a hash is blocked. Accepts `sha256:`-prefixed file hashes. */ +export async function isDenied(db: Db, hash: string): Promise { + return (await deniedHashes(db)).has(hash.replace(/^sha256:/, '')) +} diff --git a/packages/server/src/lib/slug.ts b/packages/server/src/lib/slug.ts new file mode 100644 index 0000000..b8f94d7 --- /dev/null +++ b/packages/server/src/lib/slug.ts @@ -0,0 +1,236 @@ +/** + * Slug rules. An org slug is the first path segment of every owner URL + * (`/:owner`), so it must not collide with the site's own top-level paths, now + * or later; a collection slug is the second (`/:owner/:slug`), so it must not + * collide with an org's own pages. Reservations only bind new and renamed slugs: + * an existing org or collection keeps its slug. + */ + +/** Words no org may take: today's top-level paths, and those an app usually grows. */ +export const RESERVED_ORG_SLUGS = new Set([ + // Top-level pages (test/slug.test.ts keeps this in step with web's routes). + 'admin', + 'dashboard', + 'docs', + 'explore', + 'forgot-password', + 'invitations', + 'login', + 'logout', + 'new', + 'new-org', + 'protocol', + 'records', + 'report', + 'reset-password', + 'schemas', + 'settings', + 'signup', + 'superadmin', + // Server routes and redirects. + 'agent', + 'api', + 'ark', + 'blog', + // Sign-in and accounts. + 'account', + 'accounts', + 'auth', + 'callback', + 'invite', + 'join', + 'me', + 'oauth', + 'password', + 'profile', + 'register', + 'session', + 'sessions', + 'sign-in', + 'sign-out', + 'sign-up', + 'signin', + 'signout', + 'sso', + 'user', + 'users', + 'verify', + // Organizations and people. + 'member', + 'members', + 'org', + 'organization', + 'organizations', + 'orgs', + 'people', + 'team', + 'teams', + // Browsing and the app's own nouns. + 'browse', + 'collection', + 'collections', + 'data', + 'datasets', + 'discover', + 'featured', + 'feed', + 'files', + 'home', + 'labels', + 'popular', + 'record', + 'schema', + 'search', + 'tags', + 'topics', + 'trending', + 'types', + 'versions', + // Account pages an app grows. + 'activity', + 'billing', + 'checkout', + 'inbox', + 'invoices', + 'keys', + 'messages', + 'notifications', + 'plans', + 'preferences', + 'pricing', + 'subscription', + 'tokens', + 'usage', + // About, help and legal. + 'about', + 'abuse', + 'accessibility', + 'changelog', + 'community', + 'contact', + 'cookies', + 'dmca', + 'events', + 'faq', + 'guide', + 'guides', + 'help', + 'legal', + 'license', + 'news', + 'press', + 'privacy', + 'security', + 'status', + 'support', + 'terms', + 'tutorials', + 'updates', + // Actions. + 'compare', + 'create', + 'delete', + 'diff', + 'download', + 'downloads', + 'edit', + 'export', + 'import', + 'mirror', + 'mirrors', + 'pull', + 'push', + 'query', + 'restore', + 'sync', + 'upload', + 'uploads', + // Machine paths and infrastructure. + 'app', + 'apps', + 'assets', + 'cdn', + 'cli', + 'console', + 'developer', + 'developers', + 'email', + 'graphql', + 'health', + 'llms', + 'mail', + 'mcp', + 'metrics', + 'robots', + 'rss', + 'sdk', + 'sitemap', + 'spec', + 'static', + 'v1', + 'v2', + 'webhooks', + 'www', + // Names that would read as us, or as nobody. + 'anonymous', + 'internal', + 'kf', + 'knowledge-futures', + 'knowledgefutures', + 'moderator', + 'null', + 'official', + 'root', + 'staff', + 'underlay', + 'undefined', + '404', + '500', +]) + +/** Words no collection may take: an org's own pages under `/:owner/`, now or later. */ +export const RESERVED_COLLECTION_SLUGS = new Set([ + 'settings', + 'activity', + 'billing', + 'collections', + 'members', + 'new', + 'people', + 'usage', +]) + +export const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ + +function validate(slug: unknown, reserved: Set): string | null { + if (!slug || typeof slug !== 'string') return 'Slug is required' + if (slug.length < 2) return 'Slug must be at least 2 characters' + if (slug.length > 64) return 'Slug must be at most 64 characters' + if (!SLUG_RE.test(slug)) { + return 'Slug must be lowercase alphanumeric with hyphens, and cannot start or end with a hyphen' + } + if (reserved.has(slug)) return 'That slug is reserved' + return null +} + +/** An org slug's problem, or null when it's acceptable. */ +export const validateOrgSlug = (slug: unknown) => validate(slug, RESERVED_ORG_SLUGS) + +/** A collection slug's problem, or null when it's acceptable. */ +export const validateCollectionSlug = (slug: unknown) => validate(slug, RESERVED_COLLECTION_SLUGS) + +/** + * Candidate slug for a user's default org, from their email's local part. A + * local part that is too short or reserved (`admin@…`, `support@…`) gets a + * numeric suffix from the start (`support-1`); `attempt` > 0 counts collisions. + */ +export function defaultOrgSlugCandidate(email: string, attempt = 0): string { + const local = (email.split('@')[0] ?? '') + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .slice(0, 30) + .replace(/^-+|-+$/g, '') + if (local.length < 2 || RESERVED_ORG_SLUGS.has(local)) return `${local || 'user'}-${attempt + 1}` + return attempt === 0 ? local : `${local}-${attempt}` +} diff --git a/packages/server/src/locations/locations.ts b/packages/server/src/locations/locations.ts new file mode 100644 index 0000000..4c464ae --- /dev/null +++ b/packages/server/src/locations/locations.ts @@ -0,0 +1,362 @@ +/** + * Storage locations a customer adds for mirrors (edge-redesign.md, "Placements": + * Credentials and verification). + * + * - Credentials are encrypted with the deployment's LOCATION_KEY (AES-GCM) and + * never returned to clients. + * - A location's credentials read and write: the check reads back what it wrote. + * - The signing region comes from the endpoint, and the check corrects it when + * the bucket names another one (S3 answers with `x-amz-bucket-region`). + * - An endpoint is a user-supplied URL: it must be https, and its host may not + * be a private name or address (dev and test deployments allow local http). + * Requests to it go through `ports.locationFetch`, which on Node also refuses + * hosts that resolve to private addresses. + * - The setup check writes a small object under the prefix, reads it back, and + * tries an anonymous read: a location that serves objects to anyone is + * flagged public, and may hold public sets only. + */ +import { + type LifecycleRule, + PrefixedStore, + S3Error, + S3Store, + type Store, + s3Store, +} from '@underlay/protocol' +import { and, asc, eq, isNull, lt, ne, or } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import type { Ports } from '../ports.js' +import { ipKind, isPrivateIp } from '../webhooks/webhooks.js' + +export type LocationRow = typeof schema.storageLocations.$inferSelect + +export interface LocationCredentials { + accessKeyId: string + secretAccessKey: string +} + +const enc = new TextEncoder() +const dec = new TextDecoder() +const b64 = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +const unb64 = (s: string) => + Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)) + +async function aesKey(ports: Ports): Promise { + if (!ports.locationKey) + throw new LocationError('LOCATION_KEY is not configured on this deployment') + const raw = unb64(ports.locationKey) + if (raw.length !== 32) throw new LocationError('LOCATION_KEY must be 32 bytes, base64url') + return crypto.subtle.importKey('raw', raw as Uint8Array, 'AES-GCM', false, [ + 'encrypt', + 'decrypt', + ]) +} + +export class LocationError extends Error { + constructor(message: string) { + super(message) + this.name = 'LocationError' + } +} + +/** `v1..`, base64url; the location id is authenticated data. */ +export async function encryptCredentials( + ports: Ports, + locationId: string, + creds: LocationCredentials, +): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)) + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, additionalData: enc.encode(locationId) }, + await aesKey(ports), + enc.encode(JSON.stringify(creds)), + ) + return `v1.${b64(iv)}.${b64(new Uint8Array(ct))}` +} + +export async function decryptCredentials( + ports: Ports, + loc: LocationRow, +): Promise { + const [v, iv, ct] = (loc.credentials ?? '').split('.') + if (v !== 'v1' || !iv || !ct) throw new LocationError(`Location ${loc.id} has no credentials`) + const pt = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: unb64(iv) as Uint8Array, + additionalData: enc.encode(loc.id), + }, + await aesKey(ports), + unb64(ct) as Uint8Array, + ) + return JSON.parse(dec.decode(pt)) as LocationCredentials +} + +/** A Request as plain fetch arguments (a Request's fields don't spread). */ +export async function requestInit(req: Request): Promise { + const body = req.body ? await req.arrayBuffer() : null + return { method: req.method, headers: req.headers, ...(body ? { body } : {}) } +} + +/** The fetch for customer endpoints. */ +export const fetchFor = (ports: Ports) => + ports.locationFetch ?? + (async (req: Request) => ports.outboundFetch(req.url, await requestInit(req))) + +/** The repository store of a customer location: its bucket under its prefix. */ +export async function locationStore(ports: Ports, loc: LocationRow): Promise { + if (loc.kind !== 's3') throw new LocationError(`Location ${loc.id} is not a customer location`) + const creds = await decryptCredentials(ports, loc) + const store = s3Store({ + endpoint: loc.endpoint!, + bucket: loc.bucket!, + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + region: loc.region ?? regionFor(loc.endpoint!), + fetch: fetchFor(ports), + }) + return loc.prefix ? new PrefixedStore(store, loc.prefix) : store +} + +export const locationChecks = { everyMs: 24 * 60 * 60 * 1000, perSweep: 3 } + +/** + * Re-check customer locations not checked for a day, a few at a time (the cron + * sweep): credentials get revoked, buckets change, lifecycle rules appear. + */ +export async function recheckLocations(ports: Ports): Promise { + const due = await ports.db + .select({ id: schema.storageLocations.id }) + .from(schema.storageLocations) + .where( + and( + eq(schema.storageLocations.kind, 's3'), + ne(schema.storageLocations.status, 'disabled'), + or( + isNull(schema.storageLocations.checkedAt), + lt(schema.storageLocations.checkedAt, new Date(Date.now() - locationChecks.everyMs)), + ), + ), + ) + .orderBy(asc(schema.storageLocations.checkedAt)) + .limit(locationChecks.perSweep) + await ports.jobs.enqueueBatch(due.map((l) => ({ type: 'locations.check', locationId: l.id }))) + return due.length +} + +registerJob('locations.check', async (job, ports) => { + const [loc] = await ports.db + .select() + .from(schema.storageLocations) + .where(eq(schema.storageLocations.id, String(job.locationId))) + if (loc && loc.kind === 's3' && loc.status !== 'disabled') await checkLocation(ports, loc) +}) + +/** Validate a customer endpoint URL: a plain https origin on a public host. */ +export function checkEndpoint(endpoint: unknown, allowLocal = false): string | null { + const example = 'like https://s3.us-east-1.amazonaws.com' + if (typeof endpoint !== 'string' || !endpoint.trim()) return `Enter the endpoint, ${example}.` + let u: URL + try { + u = new URL(endpoint) + } catch { + return `The endpoint isn't a web address. Enter one ${example}.` + } + if (u.protocol !== 'https:' && !(allowLocal && u.protocol === 'http:')) + return 'The endpoint must start with https://.' + if (u.username || u.password || u.search || u.hash || u.pathname.replace(/\/+$/, '')) + return `Enter just the address, ${example}, with no path or login.` + if (allowLocal) return null + const host = u.hostname + .replace(/^\[|\]$/g, '') + .toLowerCase() + .replace(/\.$/, '') + if ( + host === 'localhost' || + host.endsWith('.localhost') || + host.endsWith('.local') || + host.endsWith('.internal') || + !host.includes('.') || + (ipKind(host) && isPrivateIp(host)) + ) { + return 'The endpoint must be a public address, not localhost or a private network.' + } + return null +} + +/** + * The signing region an endpoint names: AWS and most S3-compatible hosts do + * (`s3.eu-west-2.amazonaws.com`, `s3.us-west-004.backblazeb2.com`), R2 signs + * with `auto`, and AWS's global endpoint is us-east-1. Null for other hosts. + */ +export function namedRegion(endpoint: string): string | null { + let host: string + try { + host = new URL(endpoint).hostname.toLowerCase() + } catch { + return null + } + if (host.endsWith('.r2.cloudflarestorage.com')) return 'auto' + if (host === 's3.amazonaws.com' || host.endsWith('.s3.amazonaws.com')) return 'us-east-1' + const named = /(?:^|\.)s3[.-](?:dualstack\.)?([a-z]{2}(?:-[a-z]+)+-\d+)\./.exec(host) + return named?.[1] ?? null +} + +/** + * The region to sign with: the one the endpoint names, else us-east-1, which + * S3-compatible servers accept by default. The location check corrects it + * from the bucket's answer. + */ +export const regionFor = (endpoint: string) => namedRegion(endpoint) ?? 'us-east-1' + +/** A failed check in words for the person who set the location up. */ +export function explainCheckError(err: unknown, loc: Pick) { + let host = loc.endpoint ?? '' + try { + host = new URL(host).host + } catch {} + if (err instanceof LocationError) return err.message + if (err instanceof S3Error) { + // Cloudflare answers 530 (error 1016) for a host with no DNS record. + if (err.status === 530) return `Couldn't reach ${host}. Check the endpoint.` + if (err.status === 403) + return `The bucket refused these keys. Check the access key, the secret and the bucket's permissions. (${err.message})` + if (err.status === 404) return `No bucket named "${loc.bucket}" at ${host}.` + return err.message + } + // fetch itself failed: DNS, refused connection, TLS, timeout. + return `Couldn't reach ${host}: ${(err as Error).message}` +} + +/** + * The region a bucket says it's in. S3 sends `x-amz-bucket-region` on a HEAD of + * the bucket, even when it refuses the request; other servers send nothing. + */ +async function bucketRegion(ports: Ports, loc: LocationRow): Promise { + const url = `${loc.endpoint!.replace(/\/+$/, '')}/${encodeURIComponent(loc.bucket!)}` + const res = await fetchFor(ports)(new Request(url, { method: 'HEAD' })).catch(() => null) + await res?.body?.cancel() + const region = res?.headers.get('x-amz-bucket-region')?.trim() + return region && /^[a-z0-9-]{1,64}$/.test(region) ? region : null +} + +export interface CheckResult { + ok: boolean + /** Objects under the prefix can be read without credentials. */ + publicRead: boolean + readBack: boolean + error: string | null + /** Things that don't stop mirroring but should be fixed (a lifecycle rule we can't read, say). */ + warnings: string[] +} + +/** + * Bucket lifecycle rules that touch a location's prefix. A rule that expires + * objects there would delete what mirrors wrote: the check fails. One that moves + * them to another storage class, or rules these credentials can't read, warn. + */ +async function lifecycleProblems(store: Store, prefix: string) { + const s3 = store instanceof PrefixedStore ? store.inner : store + if (!(s3 instanceof S3Store)) return { error: null, warnings: [] as string[] } + const rules = await s3.lifecycleRules() + const where = prefix ? `${prefix.replace(/\/+$/, '')}/` : '' + const shown = where || 'the whole bucket' + if (rules === 'none') return { error: null, warnings: [] } + if (rules === 'unreadable') { + return { + error: null, + warnings: [ + `These credentials can't read the bucket's lifecycle rules; make sure none deletes objects under ${shown}.`, + ], + } + } + const touches = (r: LifecycleRule) => where.startsWith(r.prefix) || r.prefix.startsWith(where) + const deleting = rules.filter((r) => r.expires && touches(r)) + const moving = rules.filter((r) => r.transitions && touches(r)) + return { + error: deleting.length + ? `Lifecycle rule ${deleting.map((r) => `"${r.id || r.prefix || '(unnamed)'}"`).join(', ')} deletes objects under ${shown}, where mirrors write. Exclude it from the rule.` + : null, + warnings: moving.map( + (r) => + `Lifecycle rule "${r.id || r.prefix || '(unnamed)'}" moves objects under ${shown} to another storage class, where reads may fail.`, + ), + } +} + +const CHECK_KEY = '.underlay/check.json' + +/** Write the check object; on a failure, retry once in the region the bucket names. */ +async function writeCheck(ports: Ports, loc: LocationRow, body: string) { + try { + const store = await locationStore(ports, loc) + await store.put(CHECK_KEY, body, { contentType: 'application/json' }) + return { store, loc } + } catch (err) { + const region = await bucketRegion(ports, loc) + if (!region || region === loc.region) throw err + const moved = { ...loc, region } + const store = await locationStore(ports, moved) + await store.put(CHECK_KEY, body, { contentType: 'application/json' }) + await ports.db + .update(schema.storageLocations) + .set({ region }) + .where(eq(schema.storageLocations.id, loc.id)) + return { store, loc: moved } + } +} + +/** Write, read back and probe for anonymous reads; record the outcome. */ +export async function checkLocation(ports: Ports, loc: LocationRow): Promise { + const result: CheckResult = { + ok: false, + publicRead: false, + readBack: false, + error: null, + warnings: [], + } + try { + const nonce = crypto.randomUUID() + const written = await writeCheck( + ports, + loc, + JSON.stringify({ nonce, at: new Date().toISOString() }), + ) + const store = written.store + loc = written.loc + const back = await store.get(CHECK_KEY) + if (!back || !(await back.text()).includes(nonce)) { + throw new LocationError('Wrote a check object but could not read it back') + } + result.readBack = true + const key = [loc.prefix, CHECK_KEY].filter(Boolean).join('/') + const url = `${loc.endpoint!.replace(/\/+$/, '')}/${loc.bucket}/${key.split('/').map(encodeURIComponent).join('/')}` + const anon = await fetchFor(ports)(new Request(url)).catch(() => null) + result.publicRead = anon?.ok === true + await anon?.body?.cancel() + const lifecycle = await lifecycleProblems(store, loc.prefix) + result.warnings.push(...lifecycle.warnings) + if (lifecycle.error) throw new LocationError(lifecycle.error) + result.ok = true + } catch (err) { + result.error = explainCheckError(err, loc).slice(0, 500) + } + await ports.db + .update(schema.storageLocations) + .set({ + status: result.ok ? 'active' : 'broken', + // A working location keeps its warnings where the page shows problems. + lastError: + result.error ?? (result.warnings.length ? `Warning: ${result.warnings.join(' ')}` : null), + checkedAt: new Date(), + ...(result.ok ? { verifiedAt: new Date() } : {}), + }) + .where(eq(schema.storageLocations.id, loc.id)) + return result +} diff --git a/packages/server/src/locations/mirror.ts b/packages/server/src/locations/mirror.ts new file mode 100644 index 0000000..5454ce2 --- /dev/null +++ b/packages/server/src/locations/mirror.ts @@ -0,0 +1,361 @@ +/** + * Bucket mirrors (edge-redesign.md, "Placements": Sync). A mirror placement is a + * complete repository of a collection in a customer location, kept current by + * jobs; it never blocks a commit. + * + * mirror.version copy the next version a placement lacks, then queue the + * one after; one job per (placement, step) + * + * A version's copy is exactly its sync work (`versionWork`): the objects it + * reaches that the previous version doesn't, then the file bytes it added, the + * private set object (public+private placements only), the root, then + * collection.json, the signed log entry and head.json, in the repository's + * write order. Backfill is the same loop from version 1. A copy stops after a + * budget and resumes from a cursor (the tree and last key done), so no single + * job copies more than a few thousand objects. Copies are tracked in the + * placement row, never found by HEAD requests: a write-only mirror can't read. + * Deletes don't propagate. + */ +import { + type CollectionInfo, + diffTrees, + entryHash, + type FileEntry, + fileTree, + jcs, + keys, + type LogEntry, + putFromParts, + readCollectionInfo, + readParts, + RepoSource, + type Store, + type SyncSets, + treeObjects, + versionWork, +} from '@underlay/protocol' +import { and, asc, eq, isNull, lt, ne, or } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import type { Ports } from '../ports.js' +import { locationStore } from './locations.js' + +const LEASE_MS = 15 * 60 * 1000 + +export const mirrorConfig = { + /** Objects one job copies before it saves a cursor and queues the rest. */ + objectsPerJob: 2000, + /** File bytes one job copies (at least one file). */ + fileBytesPerJob: 256 * 1024 * 1024, + /** Files are read and written in parts of this size (S3 joins parts under 5 MiB). */ + partBytes: 8 * 1024 * 1024, +} + +type Placement = typeof schema.placements.$inferSelect + +/** + * The org's default mirror placements, made concrete for one collection: a + * collection row per org default it doesn't override, so each collection has + * its own progress. Returns the collection's mirror placements. + */ +export async function collectionMirrors(ports: Ports, collectionId: string): Promise { + const { db } = ports + const [col] = await db + .select({ organizationId: schema.collections.organizationId }) + .from(schema.collections) + .where(eq(schema.collections.id, collectionId)) + if (!col) return [] + const defaults = await db + .select() + .from(schema.placements) + .where( + and( + eq(schema.placements.organizationId, col.organizationId), + isNull(schema.placements.collectionId), + eq(schema.placements.role, 'mirror'), + ), + ) + for (const d of defaults) { + await db + .insert(schema.placements) + .values({ + collectionId, + locationId: d.locationId, + role: 'mirror', + sets: d.sets, + state: 'backfilling', + }) + .onConflictDoNothing() + } + return db + .select() + .from(schema.placements) + .where( + and(eq(schema.placements.collectionId, collectionId), eq(schema.placements.role, 'mirror')), + ) +} + +/** Queue the next copy for every mirror of a collection (after a publish, or to catch up). */ +export async function queueMirrors(ports: Ports, collectionId: string): Promise { + const mirrors = (await collectionMirrors(ports, collectionId)).filter((p) => p.state !== 'paused') + if (mirrors.length === 0) return + await ports.jobs.enqueueBatch(mirrors.map((p) => ({ type: 'mirror.version', placementId: p.id }))) +} + +async function copyFileBytes(ports: Ports, dest: Store, hash: string, size: number): Promise { + const [row] = await ports.db + .select() + .from(schema.files) + .where(eq(schema.files.hash, hash)) + .limit(1) + if (!row) throw new Error(`File ${hash} is referenced but not stored`) + // Ranged reads into a multipart write: at most a part (or S3's 5 MiB minimum) + // of the file is held at once. + await putFromParts( + dest, + keys.file(hash), + readParts(ports.stores.fileBytes, row.storageKey, size, mirrorConfig.partBytes), + { contentType: row.mimeType }, + ) +} + +/** + * Copy (part of) the next version a placement lacks. Returns true when there is + * more to do (a job has been queued for it). + */ +export async function mirrorStep(ports: Ports, placementId: string): Promise { + const { db } = ports + const [p] = await db.select().from(schema.placements).where(eq(schema.placements.id, placementId)) + if (!p || p.role !== 'mirror' || !p.collectionId || p.state === 'paused') return false + const collectionId = p.collectionId + const [head] = await db + .select({ seq: schema.versions.seq }) + .from(schema.collections) + .innerJoin(schema.versions, eq(schema.versions.id, schema.collections.headVersionId)) + .where(eq(schema.collections.id, collectionId)) + if (!head || p.syncedSeq >= head.seq) { + if (p.state !== 'active') { + await db + .update(schema.placements) + .set({ state: 'active', lastError: null, updatedAt: new Date() }) + .where(eq(schema.placements.id, p.id)) + } + return false + } + const seq = p.syncedSeq + 1 + const versions = await db + .select() + .from(schema.versions) + .where( + and( + eq(schema.versions.collectionId, collectionId), + or(eq(schema.versions.seq, seq), eq(schema.versions.seq, seq - 1)), + ), + ) + .orderBy(asc(schema.versions.seq)) + const version = versions.find((v) => v.seq === seq) + const prev = versions.find((v) => v.seq === seq - 1) ?? null + if (!version) throw new Error(`Collection ${collectionId} has no version ${seq}`) + + const [loc] = await db + .select() + .from(schema.storageLocations) + .where(eq(schema.storageLocations.id, p.locationId)) + if (!loc || loc.status === 'disabled') return false + + // One copy at a time: a job queued while another holds the lease leaves it be + // (the holder re-checks the head when it finishes). + const started = Date.now() + const claimed = await db + .update(schema.placements) + .set({ leaseUntil: new Date(started + LEASE_MS) }) + .where( + and( + eq(schema.placements.id, p.id), + eq(schema.placements.syncedSeq, p.syncedSeq), + or( + isNull(schema.placements.leaseUntil), + lt(schema.placements.leaseUntil, new Date(started)), + ), + ), + ) + .returning({ cursor: schema.placements.cursor }) + if (claimed.length === 0) return false + // The cursor as of the claim: another job may have moved it since the first read. + const saved = claimed[0]!.cursor + + try { + const repo = await ports.stores.forCollection(collectionId) + const dest = await locationStore(ports, loc) + const sets: SyncSets = p.sets === 'public+private' ? 'all' : 'public' + const work = await versionWork(repo, version.hash, { base: prev?.hash ?? null, sets }) + const cursor = saved?.seq === seq ? saved : { seq, tree: -1, after: null } + const raw = async (key: string) => { + const obj = await repo.blobs.get(key) + if (!obj) throw new Error(`Missing object ${key}`) + return obj.bytes() + } + const save = async (tree: number, after: string | null) => { + await db + .update(schema.placements) + .set({ + cursor: { seq, tree, after }, + state: 'backfilling', + lastError: null, + leaseUntil: null, + updatedAt: new Date(), + }) + .where(eq(schema.placements.id, p.id)) + await ports.jobs.enqueue({ type: 'mirror.version', placementId: p.id }) + return true + } + let copied = 0 + + if (cursor.tree === -1) { + for (const key of work.schemas) + await dest.put(key, await raw(key), { contentType: 'application/json' }) + cursor.tree = 0 + } + for (let t = cursor.tree; t < work.trees.length; t++) { + const after = t === cursor.tree ? cursor.after : null + for await (const o of treeObjects(repo, work.trees[t]!, after)) { + await dest.put(o.key, o.bytes) + copied++ + if (o.resumeKey && copied >= mirrorConfig.objectsPerJob) return save(t, o.resumeKey) + } + } + + // File bytes the version added to the sets this placement holds. + const filesStep = work.trees.length + if (cursor.tree <= filesStep) { + const source = new RepoSource(fileTree, repo) + const added: FileEntry[] = [] + for (const tree of work.trees.filter((x) => x.kind === 'files')) { + for await (const d of diffTrees(source, tree.base, tree.target)) + if (d.after) added.push(d.after) + } + added.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) + const from = cursor.tree === filesStep ? cursor.after : null + let bytes = 0 + for (const f of added) { + if (from !== null && f.key <= from) continue + await copyFileBytes(ports, dest, f.key, f.size) + bytes += f.size + if (bytes >= mirrorConfig.fileBytesPerJob) return save(filesStep, f.key) + } + } + + // The version itself, then its log entry and the head, last. + if (work.privateSet) + await dest.put(work.privateSet, await raw(work.privateSet), { + contentType: 'application/json', + }) + await dest.put(work.rootKey, await raw(work.rootKey), { contentType: 'application/json' }) + const info = await readCollectionInfo(repo, collectionId) + if (info) + await dest.put(keys.collection(collectionId), JSON.stringify(info, null, 1), { + contentType: 'application/json', + }) + const entryKey = keys.logEntry(collectionId, seq) + const entry = await repo.blobs.get(entryKey) + if (!entry) throw new Error(`Version ${seq} has no log entry yet`) + const entryBytes = await entry.bytes() + await dest.put(entryKey, entryBytes, { contentType: 'application/json' }) + // The mirror's head names the version just copied, never one it lacks. + const logEntry = JSON.parse(new TextDecoder().decode(entryBytes)) as LogEntry + await dest.put( + keys.head(collectionId), + jcs({ entryHash: entryHash(logEntry), seq, versionHash: version.hash }), + { contentType: 'application/json' }, + ) + + // A version published while this ran counts too. + const [latest] = await db + .select({ seq: schema.versions.seq }) + .from(schema.collections) + .innerJoin(schema.versions, eq(schema.versions.id, schema.collections.headVersionId)) + .where(eq(schema.collections.id, collectionId)) + const more = seq < (latest?.seq ?? head.seq) + await db + .update(schema.placements) + .set({ + syncedSeq: seq, + cursor: null, + state: more ? 'backfilling' : 'active', + lastError: null, + leaseUntil: null, + updatedAt: new Date(), + }) + .where(eq(schema.placements.id, p.id)) + if (more) await ports.jobs.enqueue({ type: 'mirror.version', placementId: p.id }) + return more + } catch (err) { + await db + .update(schema.placements) + .set({ + state: 'error', + lastError: (err as Error).message.slice(0, 500), + leaseUntil: null, + updatedAt: new Date(), + }) + .where(eq(schema.placements.id, p.id)) + throw err + } +} + +/** + * Copy a rewritten collection.json to every mirror that already holds a version + * of the collection (spec 11.1: a mirror carries each rewrite). A mirror that + * holds none gets the file with its first version. A failure is logged and left: + * the next version's copy writes the current file anyway. + */ +export async function mirrorCollectionInfo( + ports: Ports, + collectionId: string, + info: CollectionInfo, +): Promise { + const mirrors = (await collectionMirrors(ports, collectionId)).filter( + (p) => p.syncedSeq > 0 && p.state !== 'paused', + ) + for (const p of mirrors) { + const [loc] = await ports.db + .select() + .from(schema.storageLocations) + .where(eq(schema.storageLocations.id, p.locationId)) + if (!loc || loc.status === 'disabled') continue + try { + const dest = await locationStore(ports, loc) + await dest.put(keys.collection(collectionId), JSON.stringify(info, null, 1), { + contentType: 'application/json', + }) + } catch (err) { + console.error(`[mirror] collection.json for ${collectionId} to placement ${p.id} failed`, err) + } + } +} + +/** Placements behind their collection's head and not already being worked: for the sweep. */ +export async function laggingPlacements(ports: Ports): Promise { + const rows = await ports.db + .select({ + id: schema.placements.id, + synced: schema.placements.syncedSeq, + head: schema.versions.seq, + }) + .from(schema.placements) + .innerJoin(schema.collections, eq(schema.collections.id, schema.placements.collectionId)) + .innerJoin(schema.versions, eq(schema.versions.id, schema.collections.headVersionId)) + .where( + and( + eq(schema.placements.role, 'mirror'), + ne(schema.placements.state, 'paused'), + lt(schema.placements.syncedSeq, schema.versions.seq), + ), + ) + return rows.map((r) => r.id) +} + +registerJob('mirror.version', async (job, ports) => { + await mirrorStep(ports, String(job.placementId)) +}) diff --git a/packages/server/src/node/guarded-fetch.ts b/packages/server/src/node/guarded-fetch.ts new file mode 100644 index 0000000..edd3f8f --- /dev/null +++ b/packages/server/src/node/guarded-fetch.ts @@ -0,0 +1,20 @@ +/** + * Node only: fetch for user-supplied URLs that refuses private addresses. + * + * Resolves the host first and refuses if any address is private. There is a + * window between this lookup and the connection (DNS rebinding); v1 closed it by + * pinning the address with an undici Agent, which is the next step here if Node + * deployments take untrusted webhook URLs. Workers have no private network. + */ +import { lookup } from 'node:dns/promises' + +import { ipKind, isPrivateIp } from '../webhooks/webhooks.js' + +export async function guardedFetch(url: string, init: RequestInit): Promise { + const host = new URL(url).hostname.replace(/^\[|\]$/g, '') + const addresses = ipKind(host) ? [{ address: host }] : await lookup(host, { all: true }) + if (addresses.some((a) => isPrivateIp(a.address))) { + throw new Error('Webhook host resolves to a private address') + } + return fetch(url, init) +} diff --git a/packages/server/src/node/main.ts b/packages/server/src/node/main.ts new file mode 100644 index 0000000..51555d0 --- /dev/null +++ b/packages/server/src/node/main.ts @@ -0,0 +1,214 @@ +/** + * Node entry: one process serving the app, running jobs from the SQLite jobs + * table, and serving presigned `/_blob/…` URLs when blobs are on the filesystem. + * + * Env: + * PORT (4200), APP_URL, DEPLOYMENT + * DB_URL file:./data/underlay.sqlite + * BLOB_DIR use the filesystem blob store under this directory, or + * S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION + * BLOB_URL_SECRET HMAC key for filesystem presigned URLs + * REPO_PREFIX (repo), INTERNAL_PREFIX (internal) key prefixes in the platform bucket + * SIGNING_KEY Ed25519 private key seed (base64url) that signs version logs + * LOCATION_KEY 32 bytes (base64url) that encrypt customer storage credentials + * S3_PUBLIC_BUCKET, ASSETS_BASE_URL world-readable bucket for org logos (same S3 + * endpoint and keys as S3_BUCKET, as in v1) and its public URL; + * without them avatar uploads answer 503 + * SESSION_SECRET, OIDC_ISSUER_URL, OIDC_ISSUER_INTERNAL_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET + * OIDC_ACCOUNT_URL the KF account site, linked from the user menu + * better-auth and KF Auth (same names as v1's .env files) + */ +import { relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { serve } from '@hono/node-server' +import { serveStatic } from '@hono/node-server/serve-static' +import { + ed25519Signer, + type FileStore, + fileStore, + generateSigningKey, + s3Store, + serveSignedBlob, + type Signer, +} from '@underlay/protocol' +import { Hono } from 'hono' + +import '../handlers.js' +import { createApp, type RenderPage } from '../app.js' +import { authenticator, createAuth } from '../auth/auth.js' +import { createKf } from '../auth/kf.js' +import { bufferedUsageSink } from '../billing/usage.js' +import { MemoryCache } from '../cache.js' +import { openNodeDb } from '../db/node.js' +import { inertBlobResponse } from '../files/files.js' +import { drainSqliteJobs, SqliteJobs } from '../jobs.js' +import { memoryRateLimiter } from '../lib/limits.js' +import { requestInit } from '../locations/locations.js' +import type { Ports, PresigningStore, PublicAssets } from '../ports.js' +import { createStores } from '../stores.js' +import { guardedFetch } from './guarded-fetch.js' + +const env = process.env +const port = Number(env.PORT ?? 4200) +const appUrl = env.APP_URL ?? `http://localhost:${port}` + +const db = await openNodeDb(env.DB_URL ?? 'file:./data/underlay.sqlite') + +let blobs: PresigningStore +let fsBlobs: FileStore | null = null +if (env.S3_ENDPOINT) { + blobs = s3Store({ + endpoint: env.S3_ENDPOINT, + bucket: env.S3_BUCKET ?? 'underlay', + accessKeyId: env.S3_ACCESS_KEY ?? '', + secretAccessKey: env.S3_SECRET_KEY ?? '', + region: env.S3_REGION ?? 'auto', + }) +} else { + const local = fileStore(env.BLOB_DIR ?? './data/blobs', { + publicUrl: appUrl, + secret: env.BLOB_URL_SECRET ?? 'dev-blob-secret', + }) + fsBlobs = local + blobs = local +} + +let publicAssets: PublicAssets | undefined +if (env.S3_ENDPOINT && env.S3_PUBLIC_BUCKET && env.ASSETS_BASE_URL) { + publicAssets = { + store: s3Store({ + endpoint: env.S3_ENDPOINT, + bucket: env.S3_PUBLIC_BUCKET, + accessKeyId: env.S3_ACCESS_KEY ?? '', + secretAccessKey: env.S3_SECRET_KEY ?? '', + region: env.S3_REGION ?? 'auto', + }), + baseUrl: env.ASSETS_BASE_URL.replace(/\/+$/, ''), + } +} + +let signingKey = env.SIGNING_KEY +if (!signingKey) { + signingKey = await generateSigningKey() + console.warn('[underlay] SIGNING_KEY is not set; version logs are signed with a throwaway key') +} +const signer: Promise = ed25519Signer(signingKey) + +let kick: () => void = () => {} +const cache = new MemoryCache() +const ports: Ports = { + db, + stores: createStores(db, cache, { + bucket: blobs, + repoPrefix: env.REPO_PREFIX ?? 'repo', + internalPrefix: env.INTERNAL_PREFIX ?? 'internal', + }), + cache, + signer: () => signer, + jobs: { + async enqueue(job, opts) { + await jobsTable.enqueue(job, opts) + kick() + }, + async enqueueBatch(js) { + await jobsTable.enqueueBatch(js) + kick() + }, + }, + outboundFetch: guardedFetch, + // Customer storage endpoints are user-supplied URLs too. + locationFetch: async (req) => guardedFetch(req.url, await requestInit(req)), + ...(env.LOCATION_KEY ? { locationKey: env.LOCATION_KEY } : {}), + ...(publicAssets ? { publicAssets } : {}), + waitUntil: (p) => { + p.catch((err) => console.error('[waitUntil]', err)) + }, + // RATE_LIMIT=off for load tests and local tools. + ...(env.RATE_LIMIT === 'off' ? {} : { rateLimit: memoryRateLimiter() }), +} +// Usage events, buffered in the process and written every few seconds. +ports.usage = bufferedUsageSink(() => ports) +const jobsTable = new SqliteJobs(db) + +// One runner loop: wakes on enqueue and every few seconds for delayed jobs. +let running = false +const runJobs = async () => { + if (running) return + running = true + try { + await drainSqliteJobs(ports) + } catch (err) { + console.error('[jobs]', err) + } finally { + running = false + } +} +kick = () => void runJobs() +setInterval(kick, 5000).unref() +// The housekeeping Cloudflare's cron runs every 10 minutes (wrangler.jsonc triggers). +setInterval( + () => + void jobsTable + .enqueue({ type: 'maintenance.sweep' }) + .catch((err) => console.error('[maintenance]', err)), + 10 * 60 * 1000, +).unref() + +const config = { + appUrl, + deployment: env.DEPLOYMENT ?? 'dev', + kfAuthUrl: env.OIDC_ISSUER_URL, + kfAccountUrl: env.OIDC_ACCOUNT_URL, +} + +// The UI (packages/web, built with `pnpm --filter @underlay/web build`). Without +// a build, the API still runs and pages are 404. +let renderPage: RenderPage | undefined +const clientDir = fileURLToPath(new URL('../../../web/dist/client', import.meta.url)) +try { + renderPage = (await import('@underlay/web')).renderPage +} catch { + console.warn('[underlay] @underlay/web is not built; serving the API only') +} +const staticFiles = serveStatic({ root: relative(process.cwd(), clientDir) }) +const oidc = { + issuerUrl: env.OIDC_ISSUER_URL ?? 'http://localhost:3000', + internalUrl: env.OIDC_ISSUER_INTERNAL_URL ?? env.OIDC_ISSUER_URL ?? 'http://localhost:3000', + clientId: env.OIDC_CLIENT_ID ?? 'kf_underlay', + clientSecret: env.OIDC_CLIENT_SECRET ?? '', +} +const kf = createKf(db, { ...oidc, internalApiKey: env.AUTH_INTERNAL_API_KEY }) +const auth = createAuth( + db, + { appUrl, secret: env.SESSION_SECRET ?? 'dev-secret-change-me', oidc }, + ports.waitUntil, + kf, +) +const app = createApp(() => ({ + ports, + config, + authenticate: authenticator(() => auth), + kf, + authHandler: (req) => auth.handler(req), + ...(renderPage ? { renderPage } : {}), +})) + +/** Static files from the client build, falling back to the app. */ +const staticApp = new Hono() +staticApp.use('*', staticFiles) +staticApp.all('*', (c) => app.fetch(c.req.raw)) +const staticOrApp = (req: Request) => staticApp.fetch(req) + +serve({ + port, + fetch: (req) => { + const path = new URL(req.url).pathname + // Bytes served from the app's origin: never rendered here (files/files.ts). + if (fsBlobs && path.startsWith('/_blob/')) + return serveSignedBlob(fsBlobs, req).then(inertBlobResponse) + if (!path.startsWith('/api/') && /\.[a-z0-9]+$/i.test(path)) return staticOrApp(req) + return app.fetch(req) + }, +}) +console.log(`underlay v2 (node) listening on ${appUrl}`) diff --git a/packages/server/src/ports.ts b/packages/server/src/ports.ts new file mode 100644 index 0000000..08aabc3 --- /dev/null +++ b/packages/server/src/ports.ts @@ -0,0 +1,108 @@ +import type { Cache, PresigningStore, Repo, Signer, Store } from '@underlay/protocol' +import type { LibSQLDatabase } from 'drizzle-orm/libsql' + +/** + * The ports the server is written against. Cloudflare and Node each supply + * adapters; nothing outside the adapters knows which runtime it's on. + * + * Stores repositories resolved per collection from its placement (never a global + * bucket), plus the platform's internal area (sessions, uploads, reference log) + * Db Drizzle sqlite-core over D1 or libsql — async, batches only (no interactive transactions) + * Jobs Cloudflare Queues, or a SQLite jobs table polled by the Node process + * Cache Cache API on Workers, in-memory LRU on Node + */ +import type { UsageSink } from './billing/usage.js' +import type * as schema from './db/schema.js' + +export type { Cache, PresigningStore, Store } from '@underlay/protocol' + +/** + * Drizzle over SQLite. Both adapters are async and support `db.batch([...])`, + * which runs statements atomically. Don't use `db.transaction`: D1 doesn't have + * interactive transactions, so code that works on Node would break on Workers. + */ +export type Db = LibSQLDatabase + +/** Messages carry ids only (Queues caps messages at 128 KB); data is in SQLite and blobs. */ +export interface JobMessage { + type: string + [k: string]: string | number | boolean | null +} + +export interface Jobs { + enqueue(job: JobMessage, opts?: { delaySeconds?: number }): Promise + enqueueBatch(jobs: JobMessage[]): Promise +} + +/** + * Where repositories live. A collection's objects are read and written through + * the repository of its primary placement; mirrors are written by sync jobs. + */ +export interface Stores { + /** The repository of a collection's primary placement. */ + forCollection(collectionId: string): Promise + /** The repository at a storage location. */ + forLocation(locationId: string): Promise + /** + * Platform-internal objects that never leave the platform and are never + * mirrored: push sessions, staging uploads, the reference log. + */ + internal: Store + /** File bytes, by the `files.storage_key` (v1 keys are relative to the bucket root). */ + fileBytes: PresigningStore + /** The canonical key of a verified file in the platform repository (relative to fileBytes). */ + canonicalFileKey(hash: string): string + /** Where a direct upload is staged before verification (relative to fileBytes; expires by lifecycle rule). */ + stagingKey(uploadId: string): string +} + +/** + * A world-readable bucket for assets that are public by nature (org logos), + * served directly at `baseUrl`. Never the platform bucket, which stays private. + */ +export interface PublicAssets { + store: Store + /** Public URL of the bucket root, no trailing slash, e.g. https://assets.underlay.org */ + baseUrl: string +} + +export interface Ports { + stores: Stores + db: Db + jobs: Jobs + cache: Cache + /** Signs version log entries (the deployment's Ed25519 key, imported once per isolate). */ + signer(): Promise + /** + * fetch for user-supplied URLs (webhooks). Workers have no private network to + * reach; on Node this resolves the host and refuses private addresses. + */ + outboundFetch(url: string, init: RequestInit): Promise + /** + * Key that encrypts storage location credentials (32 bytes, base64url; + * LOCATION_KEY). Without it, customer locations can't be added. + */ + locationKey?: string + /** + * fetch for customer storage endpoints (user-supplied URLs): on Node it must + * refuse private addresses, as outboundFetch does. + */ + locationFetch?: (req: Request) => Promise + /** The deployment's public assets bucket; absent when it has none (avatar routes answer 503). */ + publicAssets?: PublicAssets + /** Run work after the response (Workers: ctx.waitUntil; Node: fire and forget with logging). */ + waitUntil(p: Promise): void + /** + * Request budgets for /api/* (lib/limits.ts). Workers: the rate-limit binding; + * Node: in memory. Absent: no limit (tests). + */ + rateLimit?: RateLimiter + /** Where requests' usage events go (billing/usage.ts). Absent: not metered. */ + usage?: UsageSink +} + +/** Whether one more request under `key` fits its budget ('anon': per IP; 'user': per user). */ +export interface RateLimiter { + /** Spend `cost` (default 1) of the key's budget; false when it's spent. */ + check(kind: 'anon' | 'user' | 'page', key: string, cost?: number): Promise +} diff --git a/packages/server/src/push/changes.ts b/packages/server/src/push/changes.ts new file mode 100644 index 0000000..19fde75 --- /dev/null +++ b/packages/server/src/push/changes.ts @@ -0,0 +1,109 @@ +/** + * The change streams a delta session feeds the commit engine, per set and type, + * shared by the serial commit (delta.ts) and the units of a parallel commit + * (parallel.ts). + */ +import { + type Change, + gzip, + keys, + outOfLineHash, + outOfLinePointer, + type RecordEntry, + type Repo, + type SetName, +} from '@underlay/protocol' + +import type { Store } from '../ports.js' +import { mergeRuns, type RunEntry, type RunIndex } from './runs.js' + +/* + * Large records (over OUT_OF_LINE_BYTES) are staged in the session's internal + * area at upload, and their run entries carry the pointer. The commit puts each + * into the repository as it takes the entry: inside the commit's write phase, + * under its storage fence (cleanup/fence.ts). Written to the repository at + * upload instead, a record could be swept while the session was still open and + * the commit would point at nothing. + */ +const stagedKey = (sessionId: string, hash: string) => + `sessions/${sessionId}/records/${hash}.json.gz` + +/** Stage a large record for a session; returns the body line its run entry carries. */ +export async function stageOutOfLine( + internal: Store, + sessionId: string, + hash: string, + canonical: string, +): Promise { + await internal.put(stagedKey(sessionId, hash), await gzip(canonical), { + contentType: 'application/gzip', + }) + return outOfLinePointer(hash) +} + +/** Put a staged large record into the repository (an entry with an inline body needs nothing). */ +export async function takeStaged( + internal: Store, + repo: Repo, + sessionId: string, + body: string | undefined, +): Promise { + const hash = body ? outOfLineHash(body) : null + if (!hash) return + const staged = await internal.get(stagedKey(sessionId, hash)) + if (staged) { + await repo.blobs.put(keys.record(hash), await staged.bytes(), { + contentType: 'application/gzip', + ifAbsent: true, + }) + } else if (!(await repo.blobs.head(keys.record(hash)))) { + // Sessions from before staging wrote the record straight to the repository. + throw new Error(`Large record ${hash} was never staged; push it again`) + } +} + +export const isPrivateSchema = (s: Record) => s.private === true + +export const toRecordEntry = (e: RunEntry): RecordEntry => ({ + key: e.k, + hash: e.h!, + size: e.s!, + body: e.b!, +}) + +/** Which sets of a type the base has a tree in. */ +export interface BaseSets { + pub: boolean + priv: boolean +} + +/** + * The changes one set of a type gets from a delta session's runs, optionally + * limited to a key range (a commit unit). An upsert goes to its set and becomes + * a delete in the other set (when that set has a tree for the type); a delete + * goes to both. + */ +export async function* deltaChanges( + internal: Store, + repo: Repo, + sessionId: string, + runs: RunIndex[], + slug: string, + set: SetName, + privateType: boolean, + base: BaseSets, + range?: { after: string | null; through: string | null }, +): AsyncGenerator> { + const inBase = set === 'public' ? base.pub : base.priv + for await (const e of mergeRuns(internal, sessionId, runs, { type: slug, ...range })) { + if (e.x) { + if (inBase) yield { key: e.k, entry: null } + continue + } + const target = privateType || e.p ? 'private' : 'public' + if (target === set) { + await takeStaged(internal, repo, sessionId, e.b) + yield { key: e.k, entry: toRecordEntry(e) } + } else if (inBase) yield { key: e.k, entry: null } + } +} diff --git a/packages/server/src/push/compact.ts b/packages/server/src/push/compact.ts new file mode 100644 index 0000000..55e0cfe --- /dev/null +++ b/packages/server/src/push/compact.ts @@ -0,0 +1,109 @@ +/** + * Run compaction during upload. Whenever MERGE_FAN_IN runs of one tier + * accumulate in a session, a `push.compact` job merges them into one run of the + * next tier, up to MAX_TIER. A session therefore holds O(log) runs per tier plus + * capped top-tier runs, and a commit unit can read its key range from all of + * them at once. + * + * A compaction claims its inputs by setting `merging_into` to the seq of the run + * it will write, in one statement, so two jobs never merge the same run. When + * the merged run is written, one batch records it and drops the input rows. A + * commit reads the run list in one query, so it sees either the inputs or the + * merged run, never both; input objects stay until the session's objects expire, + * so a commit already reading them is unaffected. + * + * Precedence is per entry (runs.ts), so any group of runs can be merged. + */ +import { and, asc, count, eq, inArray, isNull } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import type { Ports } from '../ports.js' +import { compactRuns, MAX_TIER, MERGE_FAN_IN, readRunIndex } from './runs.js' +import { getSession, nextRunSeq } from './session.js' + +type RunKind = 'records' | 'deletes' + +/** Runs merged together: a session's records and deletes are one stream. */ +const GROUPS: Record = { + records: ['records', 'deletes'], + deletes: ['records', 'deletes'], +} + +/** After a run of `kind` and `tier` is recorded: claim a full group and queue its compaction. */ +export async function maybeCompact( + ports: Ports, + sessionId: string, + kind: RunKind, + tier: number, +): Promise { + if (tier >= MAX_TIER) return + const runs = schema.pushRuns + const free = and( + eq(runs.sessionId, sessionId), + eq(runs.tier, tier), + inArray(runs.kind, GROUPS[kind]), + isNull(runs.mergingInto), + ) + const [row] = await ports.db.select({ n: count() }).from(runs).where(free) + if ((row?.n ?? 0) < MERGE_FAN_IN) return + const out = await nextRunSeq(ports, sessionId) + if (out === null) return + const oldest = ports.db + .select({ seq: runs.seq }) + .from(runs) + .where(free) + .orderBy(asc(runs.seq)) + .limit(MERGE_FAN_IN) + const claimed = await ports.db + .update(runs) + .set({ mergingInto: out }) + .where(and(eq(runs.sessionId, sessionId), inArray(runs.seq, oldest))) + .returning({ seq: runs.seq }) + if (claimed.length < MERGE_FAN_IN) { + // Another upload claimed some of them first; leave the rest for later. + await ports.db + .update(runs) + .set({ mergingInto: null }) + .where(and(eq(runs.sessionId, sessionId), eq(runs.mergingInto, out))) + return + } + await ports.jobs.enqueue({ type: 'push.compact', sessionId, seq: out }) +} + +/** Write the compacted run `seq` from the runs claimed for it. Idempotent. */ +export async function compactClaimed(ports: Ports, sessionId: string, seq: number): Promise { + const runs = schema.pushRuns + const claimed = and(eq(runs.sessionId, sessionId), eq(runs.mergingInto, seq)) + const inputs = await ports.db.select().from(runs).where(claimed).orderBy(asc(runs.seq)) + if (inputs.length === 0) return + // A session that stopped taking uploads commits from the inputs as they are. + const session = await getSession(ports, sessionId) + if (session?.status !== 'open') return + const store = ports.stores.internal + const indexes = await Promise.all(inputs.map((r) => readRunIndex(store, sessionId, r.seq))) + const tier = inputs[0]!.tier + 1 + const index = await compactRuns(store, sessionId, indexes, seq, tier) + const kind = inputs.every((r) => r.kind === inputs[0]!.kind) ? inputs[0]!.kind : 'records' + await ports.db.batch([ + ports.db + .insert(runs) + .values({ + sessionId, + seq, + kind, + objectKey: `sessions/${sessionId}/runs/${seq}`, + count: index.blocks.reduce((n, b) => n + b.count, 0), + firstKey: index.blocks[0]?.first ?? '', + lastKey: index.blocks[index.blocks.length - 1]?.last ?? '', + tier, + }) + .onConflictDoNothing(), + ports.db.delete(runs).where(claimed), + ]) + await maybeCompact(ports, sessionId, kind, tier) +} + +registerJob('push.compact', async (job, ports) => { + await compactClaimed(ports, String(job.sessionId), Number(job.seq)) +}) diff --git a/packages/server/src/push/delta.ts b/packages/server/src/push/delta.ts new file mode 100644 index 0000000..50522e7 --- /dev/null +++ b/packages/server/src/push/delta.ts @@ -0,0 +1,373 @@ +/** + * Delta push: the path at scale. A session names a base version and uploads + * upserts (records) and deletes; the commit costs O(changes). + * + * Record lines go through the input rules, schema validation and canonical + * hashing at upload, so commit never re-reads them. Large records are staged out + * of line in the session's area right away, so runs stay small; the commit puts + * them in the repository (changes.ts). + */ +import { + checkRecordId, + checkTypeSlug, + compileSchema, + emptySet, + InputRuleError, + OUT_OF_LINE_BYTES, + parseRecordLine, + parseStrict, + recordCanonical, + SchemaError, + type SchemaValidator, + sha256Hex, + stripToSchema, + utf8ByteLength, +} from '@underlay/protocol' +import { eq } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import type { Ports } from '../ports.js' +import { type BaseVersion, commitVersion, type TypeInput } from '../versions/commit.js' +import { deltaChanges, isPrivateSchema, stageOutOfLine } from './changes.js' +import type { SessionCommitResult } from './outcome.js' +import { planParallel } from './parallel.js' +import { type RunEntry, type RunIndex, writeRun } from './runs.js' +import { + loadInputs, + nextRunSeq, + recordRun, + schemaHashes, + schemaSetError, + type SessionInputs, + sessionRuns, + type SessionRow, + transition, +} from './session.js' + +export const MAX_BATCH_BYTES = 16 * 1024 * 1024 +export const MAX_BATCH_LINES = 10_000 +const MAX_REPORTED = 100 + +export interface LineError { + line: number + recordId?: string + type?: string + errors: string[] +} + +export type IngestResult = + | { ok: true; received: number } + | { + ok: false + status: 400 | 409 | 413 | 422 + error: string + details?: LineError[] + total?: number + } + +function lines(text: string): string[] { + return text.split('\n').filter((l) => l.trim().length > 0) +} + +/** + * Parse, validate and hash one batch of record lines into run entries, a line at + * a time as they arrive (a string is split first). More than MAX_BATCH_LINES + * lines stops the batch with `tooManyLines`. + */ +export async function prepareRecords( + ports: Ports, + sessionId: string, + inputs: SessionInputs, + input: string | AsyncIterable, + opts: { stripUnknownFields: boolean }, +): Promise< + { entries: RunEntry[] } | { errors: LineError[]; total: number } | { tooManyLines: true } +> { + const errors: LineError[] = [] + let total = 0 + const fail = (e: LineError) => { + total++ + if (errors.length < MAX_REPORTED) errors.push(e) + } + const entries: RunEntry[] = [] + const internal = ports.stores.internal + const source = typeof input === 'string' ? lines(input) : input + // Compiled once per type per batch. A schema that doesn't compile (a session + // opened before the open-time check) fails its lines with a 422, not a 500. + const validators = new Map() + const validatorFor = (type: string, s: Record) => { + let v = validators.get(type) + if (v === undefined) { + try { + v = compileSchema(s) + } catch (err) { + if (!(err instanceof SchemaError)) throw err + v = `The schema for type "${type}" is refused: ${err.message}` + } + validators.set(type, v) + } + return v + } + let i = -1 + for await (const line of source) { + i++ + if (i >= MAX_BATCH_LINES) return { tooManyLines: true } + let rec + try { + rec = parseRecordLine(line) + } catch (err) { + if (!(err instanceof InputRuleError)) throw err + fail({ line: i + 1, errors: [`${err.code}: ${err.message}`] }) + continue + } + const typeSchema = inputs.schemas[rec.type] + if (!typeSchema) { + fail({ + line: i + 1, + recordId: rec.id, + type: rec.type, + errors: [`No schema for type "${rec.type}"`], + }) + continue + } + let data = rec.data + let canonical = rec.canonical + const props = typeSchema.properties as Record | undefined + if (props && data !== null && typeof data === 'object' && !Array.isArray(data)) { + const extra = Object.keys(data).filter((k) => !Object.hasOwn(props, k)) + if (extra.length > 0) { + if (!opts.stripUnknownFields) { + fail({ + line: i + 1, + recordId: rec.id, + type: rec.type, + errors: [ + `Fields not in the schema: ${extra.join(', ')} (set strip_unknown_fields to drop them)`, + ], + }) + continue + } + data = stripToSchema(data as Record, props) + canonical = recordCanonical(rec.id, rec.type, data) + } + } + const validate = validatorFor(rec.type, typeSchema) + const errs = typeof validate === 'string' ? [validate] : validate(data) + if (errs.length > 0) { + fail({ line: i + 1, recordId: rec.id, type: rec.type, errors: errs }) + continue + } + const hash = sha256Hex(canonical) + const size = utf8ByteLength(canonical) + const body = + size > OUT_OF_LINE_BYTES + ? await stageOutOfLine(internal, sessionId, hash, canonical) + : canonical + const isPrivate = isPrivateSchema(typeSchema) || rec.private === true + const entry: RunEntry = { + t: rec.type, + k: rec.id, + h: hash, + s: size, + b: body, + ...(isPrivate ? { p: true } : {}), + } + entries.push(entry) + } + return total > 0 ? { errors, total } : { entries } +} + +export async function ingestRecords( + ports: Ports, + session: SessionRow, + input: string | AsyncIterable, +): Promise { + const inputs = await loadInputs(ports, session.id) + const prepared = await prepareRecords(ports, session.id, inputs, input, { + stripUnknownFields: session.stripUnknownFields, + }) + if ('tooManyLines' in prepared) { + return { ok: false, status: 413, error: `At most ${MAX_BATCH_LINES} records per batch` } + } + if ('errors' in prepared) { + return { + ok: false, + status: 422, + error: 'Invalid records', + details: prepared.errors, + total: prepared.total, + } + } + if (prepared.entries.length === 0) return { ok: false, status: 400, error: 'Empty batch' } + const seq = await nextRunSeq(ports, session.id) + if (seq === null) return { ok: false, status: 409, error: 'Session is not open' } + const index = await writeRun(ports.stores.internal, session.id, seq, prepared.entries) + await recordRun(ports, session.id, 'records', index, { records: prepared.entries.length }) + return { ok: true, received: prepared.entries.length } +} + +/** One delete line, `{"type", "id"}`, under the input rules. Throws InputRuleError. */ +function parseDeleteLine(line: string): { type: string; id: string } { + const v = parseStrict(line) + if (v === null || typeof v !== 'object' || Array.isArray(v)) + throw new InputRuleError('bad_envelope', 'Each line is {"type": …, "id": …}') + const { type, id } = v as { type?: unknown; id?: unknown } + const idError = checkRecordId(id) + if (idError) throw new InputRuleError('bad_id', idError) + const typeError = checkTypeSlug(type) + if (typeError) throw new InputRuleError('bad_type', typeError) + return { type: type as string, id: id as string } +} + +export async function ingestDeletes( + ports: Ports, + session: SessionRow, + text: string, +): Promise { + const inputs = await loadInputs(ports, session.id) + const entries: RunEntry[] = [] + const errors: LineError[] = [] + let total = 0 + const fail = (e: LineError) => { + total++ + if (errors.length < MAX_REPORTED) errors.push(e) + } + const all = lines(text) + if (all.length > MAX_BATCH_LINES) + return { ok: false, status: 413, error: `At most ${MAX_BATCH_LINES} deletes per batch` } + all.forEach((l, i) => { + let d + try { + d = parseDeleteLine(l) + } catch (err) { + if (!(err instanceof InputRuleError)) throw err + fail({ line: i + 1, errors: [`${err.code}: ${err.message}`] }) + return + } + if (!inputs.schemas[d.type]) { + fail({ + line: i + 1, + recordId: d.id, + type: d.type, + errors: [`No schema for type "${d.type}"`], + }) + } else { + entries.push({ t: d.type, k: d.id, x: true }) + } + }) + if (total > 0) return { ok: false, status: 422, error: 'Invalid deletes', details: errors, total } + if (entries.length === 0) return { ok: false, status: 400, error: 'Empty batch' } + const seq = await nextRunSeq(ports, session.id) + if (seq === null) return { ok: false, status: 409, error: 'Session is not open' } + const index = await writeRun(ports.stores.internal, session.id, seq, entries) + await recordRun(ports, session.id, 'deletes', index, {}) + return { ok: true, received: entries.length } +} + +/** The base a session commits on, from the collection head. */ +export async function headBase(ports: Ports, collectionId: string): Promise { + const [row] = await ports.db + .select({ v: schema.versions }) + .from(schema.collections) + .innerJoin(schema.versions, eq(schema.versions.id, schema.collections.headVersionId)) + .where(eq(schema.collections.id, collectionId)) + .limit(1) + return row ? asBase(row.v) : null +} + +/** A version by id as a commit base (null for none). */ +export async function versionBase( + ports: Ports, + versionId: string | null, +): Promise { + if (!versionId) return null + const [v] = await ports.db.select().from(schema.versions).where(eq(schema.versions.id, versionId)) + return v ? asBase(v) : null +} + +function asBase(v: typeof schema.versions.$inferSelect): BaseVersion { + return { + id: v.id, + seq: v.seq, + semver: v.semver, + hash: v.hash, + publicRefsRoot: v.publicRefsRoot, + privateRefsRoot: v.privateRefsRoot, + } +} + +/** Change streams for a delta session: per type, the merged runs split into the two sets. */ +async function typeInputsForDelta( + ports: Ports, + session: SessionRow, + inputs: SessionInputs, + runs: RunIndex[], + base: { + pub: Record + priv: Record + }, +): Promise { + const internal = ports.stores.internal + const repo = await ports.stores.forCollection(session.collectionId) + const hashes = schemaHashes(inputs.schemas) + return Object.entries(inputs.schemas).map(([slug, s]) => { + const privateType = isPrivateSchema(s) + const sets = { pub: !!base.pub[slug]?.root, priv: !!base.priv[slug]?.root } + const stream = (set: 'public' | 'private') => + deltaChanges(internal, repo, session.id, runs, slug, set, privateType, sets) + return { + slug, + schema: s, + schemaHash: hashes[slug]!, + public: runs.length === 0 || privateType ? null : stream('public'), + private: runs.length === 0 ? null : stream('private'), + } + }) +} + +/** Commit a delta session. Idempotent on the session status: only an open session commits. */ +export async function commitDeltaSession( + ports: Ports, + session: SessionRow, + fence: number, +): Promise { + const base = await headBase(ports, session.collectionId) + if ((base?.id ?? null) !== session.baseVersionId) { + return { status: 'base_moved', current: base?.semver ?? null } + } + const inputs = await loadInputs(ports, session.id) + // Sessions opened before the open-time check may hold a schema that doesn't + // compile; none is ever published. + const schemaErr = schemaSetError(inputs.schemas) + if (schemaErr) return { status: 'schema_refused', error: schemaErr } + const runs = await sessionRuns(ports, session.id) + const repo = await ports.stores.forCollection(session.collectionId) + const root = base ? await repo.root(base.hash) : null + const priv = root?.private ? await repo.privateSet(root.private) : null + const sets = { pub: root?.public ?? emptySet(), priv: priv ?? emptySet() } + if (await planParallel(ports, session, { inputs, runs, base, repo, fence, ...sets })) { + return { status: 'parallel' } + } + return commitVersion(ports, { + collectionId: session.collectionId, + fence, + base, + types: await typeInputsForDelta(ports, session, inputs, runs, { + pub: root?.public.types ?? {}, + priv: priv?.types ?? {}, + }), + metadata: inputs.metadata, + declaredFiles: inputs.files, + message: session.message, + pushedBy: session.userId, + appId: session.appId, + actorId: session.actorId, + pushSessionId: session.id, + validate: (s, data) => { + const errs = compileSchema(s)(data) + return errs.length > 0 ? errs : null + }, + }) +} + +export { transition } diff --git a/packages/server/src/push/finalize.ts b/packages/server/src/push/finalize.ts new file mode 100644 index 0000000..61149a5 --- /dev/null +++ b/packages/server/src/push/finalize.ts @@ -0,0 +1,72 @@ +/** + * Finishing a push session: run the commit and record the outcome on the + * session, whether it runs inside the request or as a job. The session moves + * open → committing (by the caller) → committed | failed, or back to open after + * a "Missing files" refusal; `result` holds what a synchronous commit returns, + * `error` the rejection. A large delta commit may instead plan a parallel commit + * (parallel.ts), whose assembly job settles the session later. A commit that + * runs twice settles once: the second finds its own version (outcome.ts). + */ +import { and, eq, lt } from 'drizzle-orm' + +import { fenced } from '../cleanup/fence.js' +import * as schema from '../db/schema.js' +import type { Ports } from '../ports.js' +import { commitDeltaSession } from './delta.js' +import { + commitOutcome, + type Outcome, + pendingOutcome, + type SessionCommitResult, + settleSession, +} from './outcome.js' +import { resumeParallel } from './parallel.js' +import { getSession, type SessionRow } from './session.js' + +export type { Outcome } from './outcome.js' + +export async function finalizeSession(ports: Ports, sessionId: string): Promise { + const session = await getSession(ports, sessionId) + if (!session) return { status: 400, body: { error: 'Session not found', statusCode: 400 } } + // Only a committing session commits: a job delivered again after the session + // settled, or reopened after "Missing files", reports what's recorded. + if (session.status !== 'committing') return recorded(session) + // A parallel commit already planned: make sure its jobs are queued, and wait. + if (session.commitPlan) { + await resumeParallel(ports, session) + return pendingOutcome(sessionId) + } + // The change streams are rebuilt from the session's runs on each attempt. + const outcome = await commitOutcome(ports, sessionId, () => + fenced(ports.db, (fence) => commitDeltaSession(ports, session, fence)), + ) + if (await settleSession(ports, sessionId, outcome)) return outcome + // Another run settled it first: its outcome is the session's. + const now = await getSession(ports, sessionId) + return now ? recorded(now) : outcome +} + +/** The outcome a session that isn't committing has recorded. */ +function recorded(session: SessionRow): Outcome { + if (session.status === 'committed') return { status: 201, body: session.result ?? {} } + if (session.status === 'committing') return pendingOutcome(session.id) + if (session.error) + return { + status: (session.error.statusCode ?? 400) as Outcome['status'], + body: session.error, + } + return { + status: 409, + body: { error: `Session is ${session.status}`, statusCode: 409 }, + } +} + +/** Mark sessions whose idle timeout passed as expired (storage cleanup deletes their objects later). */ +export async function expireSessions(ports: Ports): Promise { + await ports.db + .update(schema.pushSessions) + .set({ status: 'expired' }) + .where( + and(eq(schema.pushSessions.status, 'open'), lt(schema.pushSessions.expiresAt, new Date())), + ) +} diff --git a/packages/server/src/push/outcome.ts b/packages/server/src/push/outcome.ts new file mode 100644 index 0000000..2c2adeb --- /dev/null +++ b/packages/server/src/push/outcome.ts @@ -0,0 +1,177 @@ +/** + * What a finished commit means for its push session: the HTTP status and body a + * client sees (synchronously, or by polling after an async commit), and the + * session's final state. Shared by finalize.ts and the parallel commit's + * assembly (parallel.ts). + */ +import { MissingFilesError } from '@underlay/protocol' +import { eq } from 'drizzle-orm' + +import { FenceError, StorageBusyError } from '../cleanup/fence.js' +import * as schema from '../db/schema.js' +import type { Ports } from '../ports.js' +import type { CommitResult } from '../versions/commit.js' +import { SESSION_TTL_MS, transition } from './session.js' + +/** The one refusal a client can fix within the session: the session reopens (settleSession). */ +export const MISSING_FILES = 'Missing files' + +export interface Outcome { + /** 202: a parallel commit is still running. 503: storage cleanup got in the way; push again. */ + status: 201 | 202 | 409 | 422 | 400 | 503 + body: Record +} + +export type SessionCommitResult = + | CommitResult + | { status: 'base_moved'; current: string | null } + | { status: 'schema_refused'; error: string } + | { status: 'parallel' } + +export const pendingOutcome = (sessionId: string): Outcome => ({ + status: 202, + body: { session_id: sessionId, status: 'committing' }, +}) + +/** Run a commit and map its result (or a missing-files rejection) to an outcome. */ +export async function commitOutcome( + ports: Ports, + sessionId: string, + run: () => Promise, +): Promise { + let r: SessionCommitResult + try { + r = await run() + } catch (err) { + // Only after `fenced` gave up (a parallel commit can't redo its units). + if (err instanceof FenceError || err instanceof StorageBusyError) { + return { + status: 503, + body: { + error: 'Storage cleanup ran while this push was committing. Push again.', + statusCode: 503, + }, + } + } + if (!(err instanceof MissingFilesError)) throw err + return { + status: 422, + body: { + error: MISSING_FILES, + filesNeeded: err.hashes.slice(0, 100), + statusCode: 422, + }, + } + } + switch (r.status) { + case 'committed': + return { status: 201, body: committedBody(r.version) } + case 'no_changes': + return { + status: 409, + body: { + error: 'No changes detected', + message: 'The head version already has identical content.', + hash: r.versionHash, + statusCode: 409, + }, + } + case 'conflict': + case 'base_moved': { + // The head moved, or another commit won it: if that commit was this + // session's own (a job that ran twice), the push is committed. + const own = await ownVersion(ports, sessionId) + if (own) return { status: 201, body: committedBody(own) } + // currentVersion, as the 409 at open has it. + return { + status: 409, + body: { + error: 'Version conflict', + currentVersion: + r.status === 'base_moved' ? r.current : await semverOf(ports, r.headVersionId), + statusCode: 409, + }, + } + } + case 'schema_refused': + return { status: 422, body: { error: r.error, statusCode: 422 } } + case 'invalid': + return { + status: 422, + body: { + error: 'Schema validation failed', + validationErrors: r.errors, + totalErrors: r.total, + statusCode: 422, + }, + } + case 'parallel': + return pendingOutcome(sessionId) + } +} + +type VersionRow = typeof schema.versions.$inferSelect + +/** What a committed push returns, from its version row. */ +function committedBody(v: VersionRow): Record { + return { + semver: v.semver, + hash: v.hash, + recordCount: v.recordCount, + fileCount: v.fileCount, + changes: v.changes, + } +} + +/** The version this session committed, if it did. */ +async function ownVersion(ports: Ports, sessionId: string): Promise { + const [v] = await ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.pushSessionId, sessionId)) + .limit(1) + return v ?? null +} + +async function semverOf(ports: Ports, versionId: string | null): Promise { + if (!versionId) return null + const [v] = await ports.db + .select({ semver: schema.versions.semver }) + .from(schema.versions) + .where(eq(schema.versions.id, versionId)) + return v?.semver ?? null +} + +/** + * Record an outcome on the session: committing → committed | failed, or back to + * open after a "Missing files" refusal, so the client can upload them and commit + * the same session again (the error stays on the session for a polling client). + * Only a committing session moves, so a settled one is never overwritten; false + * if this call didn't move it (another run settled it first). + */ +export async function settleSession( + ports: Ports, + sessionId: string, + outcome: Outcome, +): Promise { + if (outcome.status === 202) return true + if (outcome.status === 422 && outcome.body.error === MISSING_FILES) { + // A parallel plan is dropped: the next commit plans again, over any records + // uploaded meanwhile. The old plan's jobs stop at the plan id check. + return transition(ports, sessionId, 'committing', 'open', { + error: outcome.body as never, + finalizeStartedAt: null, + commitPlan: null, + assemblyLease: null, + expiresAt: new Date(Date.now() + SESSION_TTL_MS), + }) + } + const ok = outcome.status === 201 + return transition( + ports, + sessionId, + 'committing', + ok ? 'committed' : 'failed', + ok ? { result: outcome.body } : { error: outcome.body as never }, + ) +} diff --git a/packages/server/src/push/parallel.ts b/packages/server/src/push/parallel.ts new file mode 100644 index 0000000..78a8600 --- /dev/null +++ b/packages/server/src/push/parallel.ts @@ -0,0 +1,690 @@ +/** + * Parallel commit (edge-redesign-build.md, "Parallel commit"). One commit job + * handles a few million changed records in a Worker's CPU budget. A delta + * session bigger than that splits each (set, type) merge into key-range units + * that run as separate jobs, then one assembly job builds the interior levels + * and publishes. The trees come out byte-identical to the serial merge. + * + * plan (in the push.commit job) split candidates per (set, type): the + * runs' natural-boundary marks plus natural last keys of the base + * tree's upper level. Intervals no run block touches become gaps, + * left to assembly; the rest are grouped into units of about + * `unitEntries` run entries. Each unit gets a slice object listing + * the run blocks overlapping its range, so it reads nothing else. + * commit.unit mergeTree in range mode: the range's leaves, change + * counts and file reference deltas, to an output object. + * commit.assemble one at a time (a lease on the session). If a unit's + * `through` was deleted, merge it with the following ranges + * up to the first that survived, queue the new unit, and + * stop. Otherwise assembleTree per (set, type), then + * commitVersion with the prebuilt trees. + * + * Only commits where every type is plain go parallel: no type changes privacy + * or schema, and none is removed. Those need O(type size) work that stays in + * the serial commit for now. + */ +import { + assembleTree, + bodyOfRecord, + boundaryBytes, + compareUtf8, + dropRecordBody, + FileRefDelta, + LEAF_BOUNDARY_BITS, + mergeTree, + type NodeDesc, + type RecordEntry, + recordPayloadBytes, + recordTree, + type Repo, + RepoSink, + RepoSource, + rootDesc, + type Segment, + type SetName, + type SetObject, + trailingZeros, + type TreeSummary, +} from '@underlay/protocol' +import { and, asc, count, eq, gte, inArray, isNotNull, isNull, lt, or } from 'drizzle-orm' + +import { chunks } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import type { Ports } from '../ports.js' +import { type BaseVersion, commitVersion } from '../versions/commit.js' +import { deltaChanges, isPrivateSchema } from './changes.js' +import { commitOutcome, settleSession } from './outcome.js' +import { blocksInRange, type RunBlock, type RunIndex, runMarks } from './runs.js' +import { + getSession, + loadInputs, + schemaHashes, + type SessionInputs, + type SessionRow, +} from './session.js' + +export const parallelConfig = { + /** Run entries above which a delta commit goes parallel. */ + above: 2_000_000, + /** Run entries per unit, about. */ + unitEntries: 1_000_000, + /** Level whose nodes' natural last keys are split candidates (2: about one per 4M records). */ + baseLevel: 2, +} + +/** How long an assembler holds the session before another may take over. */ +const LEASE_MS = 15 * 60 * 1000 +const SPILL_BYTES = 4 * 1024 * 1024 +const builderOpts = { + payloadBytes: recordPayloadBytes, + dropPayload: dropRecordBody, + spillBytes: SPILL_BYTES, +} + +interface CommitPlan { + id: string + /** + * The storage fence epoch read before the units wrote anything. Assembly + * publishes under it, so a deletion window during the commit fails the push + * (503, push again) rather than publishing a tree with a hole. + */ + fence?: number + base: BaseVersion | null + /** Per type: its base trees, which the units merge into. */ + types: Record +} + +interface UnitOutput { + leaves: NodeDesc[] + stats: { added: number; removed: number; updated: number } + /** File reference count deltas: [fileHash, delta]. */ + refs: [string, number][] +} + +type UnitRow = typeof schema.commitUnits.$inferSelect + +const planDir = (sessionId: string, planId: string) => `sessions/${sessionId}/commit/${planId}` + +async function putJson(ports: Ports, key: string, value: unknown) { + await ports.stores.internal.put(key, JSON.stringify(value), { contentType: 'application/json' }) +} + +async function getJson(ports: Ports, key: string): Promise { + const obj = await ports.stores.internal.get(key) + if (!obj) throw new Error(`Parallel commit object ${key} is missing`) + return JSON.parse(await obj.text()) as T +} + +const emptyTree: TreeSummary = { root: null, count: 0, bytes: 0 } +const summaryOf = (t: { root: string | null; count: number; bytes: number } | undefined) => + t ? { root: t.root, count: t.count, bytes: t.bytes } : emptyTree + +const isNatural = (key: string) => trailingZeros(boundaryBytes(key)) >= LEAF_BOUNDARY_BITS + +/** Natural last keys of the base tree's nodes at `level`, in order. */ +async function baseCandidates(repo: Repo, root: string | null, level: number): Promise { + if (root === null) return [] + const source = new RepoSource(recordTree, repo) + const top = await rootDesc(source, root) + if (top.level <= level) return [] + const out: string[] = [] + const visit = async (desc: NodeDesc): Promise => { + const node = await source.node(desc.hash) + if (node.kind !== 'node') return + for (const c of node.children) { + if (c.level === level) { + if (isNatural(c.lastKey)) out.push(c.lastKey) + } else await visit(c) + } + } + await visit(top) + return out +} + +interface Range { + after: string | null + through: string | null + gap: boolean +} + +/** + * Group the intervals between candidates into units and gaps. An interval no + * block of the type overlaps holds no change, so it's a gap; its `through` is + * then an unchanged base key (a run mark lies in a block, so its interval is + * touched), which is what assembly needs. + */ +function planRanges( + slug: string, + candidates: string[], + blocks: RunBlock[], + unitEntries: number, +): Range[] { + const n = candidates.length + 1 + // Interval i is (candidates[i − 1], candidates[i]]; a key belongs to the first + // interval whose upper candidate is ≥ it. + const intervalOf = (id: string | null) => { + if (id === null) return n - 1 + let a = 0 + let z = candidates.length + while (a < z) { + const m = (a + z) >> 1 + if (compareUtf8(candidates[m]!, id) >= 0) z = m + else a = m + 1 + } + return a + } + const idOf = (runKey: string, edge: 'first' | 'last') => { + const sep = runKey.indexOf('\u0000') + if (runKey.slice(0, sep) === slug) return runKey.slice(sep + 1) + // A block reaching into a neighbouring type spans this type's whole edge. + return edge === 'first' ? '' : null + } + // Difference arrays: how many blocks touch each interval, and its estimated + // entries (a block's count spread evenly over the intervals it spans). + const diff = new Int32Array(n + 1) + const rate = new Float64Array(n + 1) + for (const b of blocks) { + const i0 = intervalOf(idOf(b.first, 'first')) + const i1 = intervalOf(idOf(b.last, 'last')) + diff[i0]!++ + diff[i1 + 1]!-- + rate[i0]! += b.count / (i1 - i0 + 1) + rate[i1 + 1]! -= b.count / (i1 - i0 + 1) + } + const out: Range[] = [] + let unit: Range | null = null + let acc = 0 + let touched = 0 + let estimate = 0 + for (let i = 0; i < n; i++) { + touched += diff[i]! + estimate += rate[i]! + const after = i === 0 ? null : candidates[i - 1]! + const through = i === n - 1 ? null : candidates[i]! + if (touched === 0) { + if (unit) out.push(unit) + unit = null + const last = out[out.length - 1] + if (last?.gap) last.through = through + else out.push({ after, through, gap: true }) + continue + } + if (unit && acc > 0 && acc + estimate > unitEntries) { + out.push(unit) + unit = null + } + if (!unit) { + unit = { after, through, gap: false } + acc = 0 + } + unit.through = through + acc += estimate + } + if (unit) out.push(unit) + return out +} + +/** + * Plan a parallel commit for a delta session, or return false when it should + * commit serially (small, or not every type is plain). On true the units are + * queued and the assembly job settles the session. + */ +export async function planParallel( + ports: Ports, + session: SessionRow, + ctx: { + inputs: SessionInputs + runs: RunIndex[] + base: BaseVersion | null + repo: Repo + pub: SetObject + priv: SetObject + fence: number + }, +): Promise { + const { inputs, runs, pub, priv } = ctx + const total = runs.reduce((n, r) => n + r.blocks.reduce((m, b) => m + b.count, 0), 0) + if (total <= parallelConfig.above) return false + const hashes = schemaHashes(inputs.schemas) + for (const [slug, s] of Object.entries(inputs.schemas)) { + const pubBase = pub.types[slug] + const privBase = priv.types[slug] + const prior = pubBase ?? privBase + if (prior && prior.schema !== hashes[slug]) return false + if (isPrivateSchema(s) ? !!pubBase : !pubBase && !!privBase) return false + } + for (const slug of [...Object.keys(pub.types), ...Object.keys(priv.types)]) { + if (!inputs.schemas[slug]) return false + } + + const planId = crypto.randomUUID() + const dir = planDir(session.id, planId) + const plan: CommitPlan = { id: planId, base: ctx.base, fence: ctx.fence, types: {} } + const rows: (typeof schema.commitUnits.$inferInsert)[] = [] + for (const [slug, s] of Object.entries(inputs.schemas)) { + const privateType = isPrivateSchema(s) + plan.types[slug] = { + privateType, + public: summaryOf(pub.types[slug]), + private: summaryOf(priv.types[slug]), + } + const typeRuns = runs.map((ix) => ({ ix, blocks: blocksInRange(ix, { type: slug }) })) + const blocks = typeRuns.flatMap((r) => r.blocks) + if (blocks.length === 0) continue + const anyPrivate = blocks.some((b) => b.p) + const marks = runMarks(runs, { type: slug }).map((m) => m.slice(m.indexOf('\u0000') + 1)) + for (const set of (privateType ? ['private'] : ['public', 'private']) as SetName[]) { + const baseRoot = plan.types[slug]![set].root + // Nothing can reach a private tree that doesn't exist without a private upsert. + if (set === 'private' && !privateType && !baseRoot && !anyPrivate) continue + const fromBase = await baseCandidates(ctx.repo, baseRoot, parallelConfig.baseLevel) + const candidates = [...new Set([...marks, ...fromBase])].filter(isNatural).sort(compareUtf8) + const ranges = planRanges(slug, candidates, blocks, parallelConfig.unitEntries) + for (const [ord, r] of ranges.entries()) { + const id = crypto.randomUUID() + let slicesKey: string | null = null + if (!r.gap) { + slicesKey = `${dir}/${id}.slices.json` + const range = { type: slug, after: r.after, through: r.through } + const slices = typeRuns + .map(({ ix }) => ({ seq: ix.seq, tier: ix.tier, blocks: blocksInRange(ix, range) })) + .filter((x) => x.blocks.length > 0) + await putJson(ports, slicesKey, { runs: slices }) + } + rows.push({ + id, + sessionId: session.id, + planId, + set, + type: slug, + ord, + after: r.after, + through: r.through, + gap: r.gap, + status: r.gap ? 'done' : 'pending', + slicesKey, + }) + } + } + } + await putJson(ports, `${dir}/plan.json`, plan) + // D1 binds at most 100 parameters per statement: 12 columns, 8 rows. + for (let i = 0; i < rows.length; i += 8) { + await ports.db.insert(schema.commitUnits).values(rows.slice(i, i + 8)) + } + const won = await ports.db + .update(schema.pushSessions) + .set({ commitPlan: planId }) + .where( + and( + eq(schema.pushSessions.id, session.id), + eq(schema.pushSessions.status, 'committing'), + isNull(schema.pushSessions.commitPlan), + ), + ) + .returning({ id: schema.pushSessions.id }) + if (won.length === 0) { + // Another attempt planned first; its plan stands. + await ports.db.delete(schema.commitUnits).where(eq(schema.commitUnits.planId, planId)) + return true + } + await queueWork(ports, session.id, planId) + return true +} + +/** + * Commit units on the queue at once, per session and per account + * (v2-scale-review.md S3): a billion-record push plans about a thousand units, + * and they wait their turn instead of filling the bulk queue. A unit queued + * longer than `staleMs` ago and still pending counts as lost and goes again. + */ +export const unitLimits = { perSession: 32, perAccount: 64, staleMs: 30 * 60 * 1000 } + +/** Queue a plan's pending units (within the windows), or the assembly when none is left. Idempotent. */ +async function queueWork( + ports: Ports, + sessionId: string, + planId: string, + opts: { assembleOnly?: boolean } = {}, +): Promise { + const [pending] = await ports.db + .select({ id: schema.commitUnits.id }) + .from(schema.commitUnits) + .where(and(eq(schema.commitUnits.planId, planId), eq(schema.commitUnits.status, 'pending'))) + .limit(1) + if (!pending) { + await ports.jobs.enqueue({ type: 'commit.assemble', sessionId }) + return + } + if (opts.assembleOnly) return + const session = await getSession(ports, sessionId) + if (session) await topUpUnits(ports, session.userId) +} + +/** + * Put a user's waiting units on the queue, oldest session first, as far as the + * windows allow. Runs after planning and after every unit, so the windows refill + * as work finishes. A unit is claimed by setting `queuedAt`, so two top-ups + * never queue it twice. + */ +export async function topUpUnits(ports: Ports, userId: string): Promise { + const { db } = ports + const u = schema.commitUnits + const sessions = await db + .select({ id: schema.pushSessions.id, planId: schema.pushSessions.commitPlan }) + .from(schema.pushSessions) + .where( + and( + eq(schema.pushSessions.userId, userId), + eq(schema.pushSessions.status, 'committing'), + isNotNull(schema.pushSessions.commitPlan), + ), + ) + .orderBy(asc(schema.pushSessions.createdAt)) + const staleBefore = new Date(Date.now() - unitLimits.staleMs) + const inFlight = new Map() + for (const s of sessions) { + const [row] = await db + .select({ n: count() }) + .from(u) + .where( + and( + eq(u.sessionId, s.id), + eq(u.planId, s.planId!), + eq(u.status, 'pending'), + gte(u.queuedAt, staleBefore), + ), + ) + inFlight.set(s.id, row?.n ?? 0) + } + let account = [...inFlight.values()].reduce((a, b) => a + b, 0) + for (const s of sessions) { + const room = Math.min( + unitLimits.perSession - inFlight.get(s.id)!, + unitLimits.perAccount - account, + ) + if (room <= 0) continue + const waiting = or(isNull(u.queuedAt), lt(u.queuedAt, staleBefore)) + const next = await db + .select({ id: u.id }) + .from(u) + .where(and(eq(u.sessionId, s.id), eq(u.planId, s.planId!), eq(u.status, 'pending'), waiting)) + .orderBy(asc(u.ord)) + .limit(room) + if (next.length === 0) continue + const claimed: string[] = [] + for (const part of chunks(next.map((x) => x.id))) { + const rows = await db + .update(u) + .set({ queuedAt: new Date() }) + .where(and(inArray(u.id, part), eq(u.status, 'pending'), waiting)) + .returning({ id: u.id }) + claimed.push(...rows.map((r) => r.id)) + } + await ports.jobs.enqueueBatch(claimed.map((unitId) => ({ type: 'commit.unit', unitId }))) + account += claimed.length + } +} + +/** A push.commit job found a plan already in place (a retry): make sure its work is queued. */ +export async function resumeParallel(ports: Ports, session: SessionRow): Promise { + if (session.commitPlan) await queueWork(ports, session.id, session.commitPlan) +} + +/** Run one unit. Idempotent: only a pending unit of the session's current plan runs. */ +export async function runCommitUnit(ports: Ports, unitId: string): Promise { + const [unit] = await ports.db + .select() + .from(schema.commitUnits) + .where(eq(schema.commitUnits.id, unitId)) + if (!unit || unit.status !== 'pending') return + const session = await getSession(ports, unit.sessionId) + if (session?.status !== 'committing' || session.commitPlan !== unit.planId) return + const plan = await getJson(ports, `${planDir(session.id, unit.planId)}/plan.json`) + const { runs } = await getJson<{ runs: RunIndex[] }>(ports, unit.slicesKey!) + const t = plan.types[unit.type]! + const set = unit.set as SetName + const repo = await ports.stores.forCollection(session.collectionId) + const sink = new RepoSink(repo, { bodyOf: bodyOfRecord }) + const range = { after: unit.after, through: unit.through } + const changes = deltaChanges( + ports.stores.internal, + repo, + session.id, + runs, + unit.type, + set, + t.privateType, + { pub: !!t.public.root, priv: !!t.private.root }, + range, + ) + const leaves: NodeDesc[] = [] + const refs = new FileRefDelta() + const merged = await mergeTree(new RepoSource(recordTree, repo), sink, t[set].root, changes, { + ...builderOpts, + range, + leafOutput: (d) => leaves.push(d), + onChange: (before, after) => refs.record(set, before, after, unit.type), + }) + await sink.flush() + await refs.resolve(repo) + const survived = unit.through === null || merged.lastKey === unit.through + const { added, removed, updated } = merged.stats + const output: UnitOutput = { + leaves, + stats: { added, removed, updated }, + refs: [...refs.refs[set]].filter(([, n]) => n !== 0), + } + const outputKey = `${planDir(session.id, unit.planId)}/${unit.id}.out.json` + await putJson(ports, outputKey, output) + await ports.db + .update(schema.commitUnits) + .set({ status: survived ? 'done' : 'failed', outputKey }) + .where(and(eq(schema.commitUnits.id, unit.id), eq(schema.commitUnits.status, 'pending'))) + // The last unit to finish queues the assembly (more than one may; it holds a lease). + const [left] = await ports.db + .select({ id: schema.commitUnits.id }) + .from(schema.commitUnits) + .where( + and(eq(schema.commitUnits.planId, unit.planId), eq(schema.commitUnits.status, 'pending')), + ) + .limit(1) + if (!left) await ports.jobs.enqueue({ type: 'commit.assemble', sessionId: session.id }) + // A slot is free: refill this user's windows, this session's or the next one's. + await topUpUnits(ports, session.userId) +} + +/** Units grouped per (set, type) tree, in key order, without superseded ones. */ +function trees(rows: UnitRow[]): UnitRow[][] { + const groups = new Map() + for (const r of rows) { + if (r.status === 'superseded') continue + const k = `${r.set}\u0000${r.type}` + groups.set(k, [...(groups.get(k) ?? []), r]) + } + return [...groups.values()].map((g) => g.sort((a, b) => a.ord - b.ord)) +} + +/** + * Replace each failed unit, and the ranges after it up to the first whose + * `through` survived (a done unit or a gap), by one new unit over their union. + * Returns the new units' ids. + */ +async function mergeFailed(ports: Ports, sessionId: string, planId: string, rows: UnitRow[]) { + const created: string[] = [] + for (const group of trees(rows)) { + for (let i = 0; i < group.length; i++) { + if (group[i]!.status !== 'failed') continue + let j = i + while (j < group.length - 1 && group[j]!.status === 'failed') j++ + const members = group.slice(i, j + 1) + const blocks = new Map< + number, + { seq: number; tier?: number; blocks: Map } + >() + for (const m of members) { + if (!m.slicesKey) continue + const { runs } = await getJson<{ runs: RunIndex[] }>(ports, m.slicesKey) + for (const r of runs) { + const entry = blocks.get(r.seq) ?? { seq: r.seq, tier: r.tier ?? 0, blocks: new Map() } + for (const b of r.blocks) entry.blocks.set(`${b.part}:${b.offset}`, b) + blocks.set(r.seq, entry) + } + } + const id = crypto.randomUUID() + const slicesKey = `${planDir(sessionId, planId)}/${id}.slices.json` + await putJson(ports, slicesKey, { + runs: [...blocks.values()].map((r) => ({ + seq: r.seq, + tier: r.tier, + blocks: [...r.blocks.values()].sort((a, b) => a.part - b.part || a.offset - b.offset), + })), + }) + const first = members[0]! + await ports.db.batch([ + ports.db.insert(schema.commitUnits).values({ + id, + sessionId, + planId, + set: first.set, + type: first.type, + ord: first.ord, + after: first.after, + through: members[members.length - 1]!.through, + slicesKey, + }), + ports.db + .update(schema.commitUnits) + .set({ status: 'superseded' }) + .where( + inArray( + schema.commitUnits.id, + members.map((m) => m.id), + ), + ), + ]) + created.push(id) + i = j + } + } + return created +} + +/** Assemble a parallel commit and settle its session. Runs under a lease on the session. */ +export async function assembleParallel(ports: Ports, sessionId: string): Promise { + const session = await getSession(ports, sessionId) + if (session?.status !== 'committing' || !session.commitPlan) return + const planId = session.commitPlan + const now = Date.now() + const lease = await ports.db + .update(schema.pushSessions) + .set({ assemblyLease: new Date(now) }) + .where( + and( + eq(schema.pushSessions.id, sessionId), + eq(schema.pushSessions.commitPlan, planId), + or( + isNull(schema.pushSessions.assemblyLease), + lt(schema.pushSessions.assemblyLease, new Date(now - LEASE_MS)), + ), + ), + ) + .returning({ id: schema.pushSessions.id }) + if (lease.length === 0) return + const release = () => + ports.db + .update(schema.pushSessions) + .set({ assemblyLease: null }) + .where(eq(schema.pushSessions.id, sessionId)) + try { + const rows = await ports.db + .select() + .from(schema.commitUnits) + .where(eq(schema.commitUnits.planId, planId)) + .orderBy(asc(schema.commitUnits.ord)) + if (rows.some((r) => r.status === 'pending')) { + await release() + // A unit that finished while this job held the lease queued an assembly + // that found the lease taken; queue another if nothing is pending now. + await queueWork(ports, sessionId, planId, { assembleOnly: true }) + return + } + if (rows.some((r) => r.status === 'failed')) { + await mergeFailed(ports, sessionId, planId, rows) + await release() + // The merged units wait unqueued, like a new plan's. + await topUpUnits(ports, session.userId) + return + } + + const plan = await getJson(ports, `${planDir(sessionId, planId)}/plan.json`) + const repo = await ports.stores.forCollection(session.collectionId) + const source = new RepoSource(recordTree, repo) + const sink = new RepoSink(repo, { bodyOf: bodyOfRecord }) + const built: Record = {} + for (const [slug, t] of Object.entries(plan.types)) + built[slug] = { public: t.public, private: t.private } + const refs = new FileRefDelta() + const stats = { added: 0, removed: 0, updated: 0 } + for (const group of trees(rows)) { + const { set, type } = group[0]! as { set: SetName; type: string } + // Each segment's output is read once, when assembly reaches it. + const segments: Segment[] = group + .filter((u) => !u.gap) + .map((u) => ({ + after: u.after, + through: u.through, + leaves: async () => { + const out = await getJson(ports, u.outputKey!) + stats.added += out.stats.added + stats.removed += out.stats.removed + stats.updated += out.stats.updated + // A unit covers one type, so its deltas are that type's too. + for (const [h, n] of out.refs) refs.add(set, type, h, n) + return out.leaves + }, + })) + const result = await assembleTree(source, sink, plan.types[type]![set].root, segments) + built[type]![set] = summaryOf( + result.root + ? { root: result.root.hash, count: result.root.count, bytes: result.root.bytes } + : undefined, + ) + } + await sink.flush() + + const inputs = await loadInputs(ports, sessionId) + const hashes = schemaHashes(inputs.schemas) + const outcome = await commitOutcome(ports, sessionId, () => + commitVersion(ports, { + collectionId: session.collectionId, + // Plans written before the fence existed fail if any window has opened since. + fence: plan.fence ?? 0, + base: plan.base, + types: Object.entries(inputs.schemas).map(([slug, s]) => ({ + slug, + schema: s, + schemaHash: hashes[slug]!, + public: null, + private: null, + })), + metadata: inputs.metadata, + declaredFiles: inputs.files, + message: session.message, + pushedBy: session.userId, + appId: session.appId, + actorId: session.actorId, + pushSessionId: session.id, + prebuilt: { trees: built, refs, stats }, + }), + ) + await settleSession(ports, sessionId, outcome) + } catch (err) { + await release() + throw err + } +} + +registerJob('commit.unit', async (job, ports) => runCommitUnit(ports, String(job.unitId))) +registerJob('commit.assemble', async (job, ports) => assembleParallel(ports, String(job.sessionId))) diff --git a/packages/server/src/push/runs.ts b/packages/server/src/push/runs.ts new file mode 100644 index 0000000..7cd1635 --- /dev/null +++ b/packages/server/src/push/runs.ts @@ -0,0 +1,480 @@ +/** + * Sorted runs: how push sessions hold uploaded data until commit (the external + * sort of edge-redesign-build.md finding 11). + * + * Each upload batch becomes one run, sorted by (type, id). A run is a sequence + * of blocks of about 64 KB of NDJSON, each gzipped on its own and concatenated + * into part objects of a few MB, in the platform's internal area: + * + * sessions//runs//.ndjson.gz + * sessions//runs//index.json {seq, tier, blocks: [...]} + * + * The index gives each block's first and last key, entry count, byte range in + * its part, and its natural-boundary marks (below). A reader fetches only the + * blocks overlapping a key range, by range GET, so a commit unit reads its + * slice of every run without reading the rest. + * + * Precedence: an entry's precedence is the seq of the upload it came from + * (`q`, or the run's seq for an upload run). On equal keys the highest + * precedence wins: later uploads replace earlier ones. Because precedence is + * carried per entry, any group of runs can be compacted into one. + * + * Compaction happens during upload (compact.ts): when MERGE_FAN_IN runs of one + * tier accumulate they're merged into one run of the next tier, up to + * MAX_TIER. Upload batches hold at most 10,000 entries, so a top-tier run holds + * at most about 2.5M, and a compaction always fits in one job. + * + * Memory: a reader holds one block's lines and a bounded prefetch of compressed + * bytes, so a k-way merge over hundreds of runs stays within a Worker. + */ +import { + boundaryBytes, + compareUtf8, + gunzipText, + gzip, + splitLines, + trailingZeros, +} from '@underlay/protocol' + +import type { Store } from '../ports.js' + +/** One entry of a run. `k` is the record id, `t` the type. */ +export interface RunEntry { + t: string + k: string + /** Record hash (records runs). */ + h?: string + /** Canonical record size in bytes. */ + s?: number + /** Pushed as private. */ + p?: boolean + /** The canonical record (records runs). */ + b?: string + /** A delete (delta deletes runs). */ + x?: boolean + /** Precedence: the seq of the upload this entry came from (compacted runs only). */ + q?: number +} + +export interface RunBlock { + /** First and last run keys (`type\u0000id`). */ + first: string + last: string + count: number + /** Part object number, and the block's byte range in it. */ + part: number + offset: number + length: number + /** + * Natural-boundary marks: run keys of the block's upserts whose id has at + * least MARK_BITS trailing zeros in its boundary hash. They're the split-key + * candidates for parallel commit units, about one per 8k entries, so the + * planner reads no data. + */ + marks?: string[] + /** Some upsert in the block was pushed as private. */ + p?: true +} + +export interface RunIndex { + seq: number + /** 0 for an upload, n for a run compacted from runs of tier n − 1. */ + tier?: number + blocks: RunBlock[] +} + +/** A slice of one type: ids in `(after, through]`; null is unbounded. */ +export interface RunRange { + type: string + after?: string | null + through?: string | null +} + +export const BLOCK_BYTES = 64 * 1024 +export const PART_BYTES = 4 * 1024 * 1024 +/** Runs per compaction. */ +export const MERGE_FAN_IN = 16 +/** Runs of this tier are never compacted again. */ +export const MAX_TIER = 2 +/** 2^13 = 8,192: about one mark per eight leaves. */ +export const MARK_BITS = 13 +/** Compressed bytes a merge prefetches, split across its runs. */ +const PREFETCH_BUDGET = 8 * 1024 * 1024 + +/** Sort key: type, then id, by UTF-8 bytes. A NUL separator keeps types apart. */ +export const runKey = (e: { t: string; k: string }) => `${e.t}\u0000${e.k}` +export const compareRunKeys = (a: RunEntry, b: RunEntry) => + compareUtf8(a.t, b.t) || compareUtf8(a.k, b.k) + +const runPrefix = (sessionId: string, seq: number) => `sessions/${sessionId}/runs/${seq}` +const partKey = (sessionId: string, seq: number, part: number) => + `${runPrefix(sessionId, seq)}/${part}.ndjson.gz` + +/** Is this id a split-key candidate? */ +export const isMark = (id: string) => trailingZeros(boundaryBytes(id)) >= MARK_BITS + +/** Writes one run block by block. Entries must arrive sorted. */ +export class RunWriter { + readonly #blocks: RunBlock[] = [] + #lines: string[] = [] + #marks: string[] = [] + #private = false + #first: string | null = null + #last: string | null = null + #bytes = 0 + // Blocks are compressed in order on one chain, then appended to the open part. + #chain: Promise = Promise.resolve() + #queued = 0 + #part = 0 + #partChunks: Uint8Array[] = [] + #partBytes = 0 + readonly #puts: Promise[] = [] + + constructor( + readonly store: Store, + readonly sessionId: string, + readonly seq: number, + readonly tier = 0, + ) {} + + add(e: RunEntry): void { + const key = runKey(e) + if (this.#last !== null && compareUtf8(this.#last, key) >= 0) { + throw new Error(`Run entries out of order at ${JSON.stringify(key)}`) + } + const line = JSON.stringify(e) + this.#first ??= key + this.#last = key + this.#lines.push(line) + if (!e.x && isMark(e.k)) this.#marks.push(key) + if (e.p) this.#private = true + this.#bytes += line.length + if (this.#bytes >= BLOCK_BYTES) this.#flushBlock() + } + + #flushBlock() { + if (this.#lines.length === 0) return + const text = this.#lines.join('\n') + '\n' + const block = { + first: this.#first!, + last: this.#last!, + count: this.#lines.length, + ...(this.#marks.length > 0 ? { marks: this.#marks } : {}), + ...(this.#private ? { p: true as const } : {}), + } + this.#lines = [] + this.#marks = [] + this.#private = false + this.#bytes = 0 + this.#first = null + this.#queued++ + this.#chain = this.#chain.then(async () => { + const gz = await gzip(text) + this.#blocks.push({ ...block, part: this.#part, offset: this.#partBytes, length: gz.length }) + this.#partChunks.push(gz) + this.#partBytes += gz.length + this.#queued-- + if (this.#partBytes >= PART_BYTES) this.#flushPart() + }) + } + + #flushPart() { + if (this.#partBytes === 0) return + const body = new Uint8Array(this.#partBytes) + let at = 0 + for (const c of this.#partChunks) { + body.set(c, at) + at += c.length + } + const key = partKey(this.sessionId, this.seq, this.#part) + this.#puts.push(this.store.put(key, body, { contentType: 'application/gzip' })) + this.#part++ + this.#partChunks = [] + this.#partBytes = 0 + } + + /** Bounded memory: callers writing large runs await this between entries. */ + async drain(): Promise { + if (this.#queued >= 4) await this.#chain + if (this.#puts.length >= 2) await Promise.all(this.#puts.splice(0)) + } + + async finish(): Promise { + this.#flushBlock() + await this.#chain + this.#flushPart() + await Promise.all(this.#puts.splice(0)) + const index: RunIndex = { seq: this.seq, tier: this.tier, blocks: this.#blocks } + await this.store.put( + `${runPrefix(this.sessionId, this.seq)}/index.json`, + JSON.stringify(index), + { + contentType: 'application/json', + }, + ) + return index + } +} + +/** Write an in-memory batch (one upload request) as a run. */ +export async function writeRun(store: Store, sessionId: string, seq: number, entries: RunEntry[]) { + entries.sort(compareRunKeys) + // Within one batch the last occurrence of a key wins. + const w = new RunWriter(store, sessionId, seq) + for (let i = 0; i < entries.length; i++) { + const next = entries[i + 1] + if (next && compareRunKeys(entries[i]!, next) === 0) continue + w.add(entries[i]!) + } + return w.finish() +} + +export async function readRunIndex( + store: Store, + sessionId: string, + seq: number, +): Promise { + const obj = await store.get(`${runPrefix(sessionId, seq)}/index.json`) + if (!obj) throw new Error(`Run ${seq} of session ${sessionId} is missing`) + return JSON.parse(await obj.text()) as RunIndex +} + +const normalize = (range?: RunRange | string): RunRange | undefined => + typeof range === 'string' ? { type: range } : range + +/** Is the entry inside the range? */ +export function inRunRange(range: RunRange, e: { t: string; k: string }): boolean { + return ( + e.t === range.type && + (range.after == null || compareUtf8(e.k, range.after) > 0) && + (range.through == null || compareUtf8(e.k, range.through) <= 0) + ) +} + +/** + * The blocks of a run that can hold entries in the range: a contiguous span, + * found by binary search (blocks are sorted and disjoint). + */ +export function blocksInRange(index: RunIndex, range?: RunRange): RunBlock[] { + if (!range) return index.blocks + // Every key of the type sorts after `type\0` and before `type\u0001`. + const lo = `${range.type}\u0000${range.after ?? ''}` + const hi = range.through == null ? `${range.type}\u0001` : `${range.type}\u0000${range.through}` + const hiInclusive = range.through != null + const blocks = index.blocks + // The first block whose last key is past `lo`, and the first block starting past `hi`. + const search = (pred: (b: RunBlock) => boolean) => { + let a = 0 + let z = blocks.length + while (a < z) { + const m = (a + z) >> 1 + if (pred(blocks[m]!)) z = m + else a = m + 1 + } + return a + } + const start = search((b) => compareUtf8(b.last, lo) > 0) + const end = search((b) => + hiInclusive ? compareUtf8(b.first, hi) > 0 : compareUtf8(b.first, hi) >= 0, + ) + return blocks.slice(start, Math.max(start, end)) +} + +/** + * Reads one run's entries in order, optionally limited to a range. Neighbouring + * blocks of a part are fetched together, up to `spanBytes` compressed, and the + * next span is prefetched while the current one is consumed. Only the current + * block is decompressed, and lines are parsed one at a time. + */ +export class RunCursor { + readonly #spans: RunBlock[][] = [] + #span = 0 + #fetch: Promise | null = null + #blocks: Uint8Array[] = [] + #lines: string[] = [] + #line = 0 + readonly #range: RunRange | undefined + /** The current entry, or undefined once the run is exhausted. */ + head: RunEntry | undefined + + constructor( + readonly store: Store, + readonly sessionId: string, + readonly index: RunIndex, + range?: RunRange | string, + spanBytes = 1024 * 1024, + ) { + this.#range = normalize(range) + let cur: RunBlock[] = [] + let bytes = 0 + for (const b of blocksInRange(index, this.#range)) { + const prev = cur[cur.length - 1] + const contiguous = prev && prev.part === b.part && prev.offset + prev.length === b.offset + if (prev && (!contiguous || bytes + b.length > spanBytes)) { + this.#spans.push(cur) + cur = [] + bytes = 0 + } + cur.push(b) + bytes += b.length + } + if (cur.length > 0) this.#spans.push(cur) + } + + #load(i: number): Promise { + const span = this.#spans[i]! + const start = span[0]!.offset + const length = span.reduce((n, b) => n + b.length, 0) + const key = partKey(this.sessionId, this.index.seq, span[0]!.part) + const p = this.store.get(key, { offset: start, length }).then(async (obj) => { + if (!obj) throw new Error(`Run part ${key} is missing`) + const bytes = await obj.bytes() + return span.map((b) => bytes.subarray(b.offset - start, b.offset - start + b.length)) + }) + p.catch(() => {}) + return p + } + + /** Precedence of the current entry. */ + get precedence(): number { + return this.head?.q ?? this.index.seq + } + + /** Move to the next entry in range; returns it (undefined at the end). */ + async next(): Promise { + for (;;) { + while (this.#line < this.#lines.length) { + const e = JSON.parse(this.#lines[this.#line++]!) as RunEntry + if (!this.#range || inRunRange(this.#range, e)) return (this.head = e) + } + if (this.#blocks.length > 0) { + this.#lines = splitLines(await gunzipText(this.#blocks.shift()!)) + this.#line = 0 + continue + } + if (this.#span >= this.#spans.length) return (this.head = undefined) + const current = this.#fetch ?? this.#load(this.#span) + this.#span++ + this.#fetch = this.#span < this.#spans.length ? this.#load(this.#span) : null + this.#blocks = await current + } + } +} + +/** Entries of one run in order, optionally limited to one type or a range. */ +export async function* readRun( + store: Store, + sessionId: string, + index: RunIndex, + range?: RunRange | string, +): AsyncGenerator { + const c = new RunCursor(store, sessionId, index, range) + for (let e = await c.next(); e; e = await c.next()) yield e +} + +/** Cursor order: run key, then the higher precedence first. */ +const before = (a: RunCursor, b: RunCursor) => + compareRunKeys(a.head!, b.head!) || b.precedence - a.precedence + +/** A binary min-heap of cursors. */ +class CursorHeap { + readonly items: RunCursor[] = [] + get size() { + return this.items.length + } + peek(): RunCursor | undefined { + return this.items[0] + } + push(c: RunCursor) { + const a = this.items + a.push(c) + for (let i = a.length - 1; i > 0;) { + const p = (i - 1) >> 1 + if (before(a[p]!, a[i]!) <= 0) break + ;[a[p], a[i]] = [a[i]!, a[p]!] + i = p + } + } + pop(): RunCursor | undefined { + const a = this.items + const top = a[0] + const last = a.pop() + if (a.length > 0 && last) { + a[0] = last + for (let i = 0; ;) { + const l = 2 * i + 1 + const r = l + 1 + let m = i + if (l < a.length && before(a[l]!, a[m]!) < 0) m = l + if (r < a.length && before(a[r]!, a[m]!) < 0) m = r + if (m === i) break + ;[a[m], a[i]] = [a[i]!, a[m]!] + i = m + } + } + return top + } +} + +/** + * K-way merge of any number of runs by (type, id), with a heap. On equal keys + * the entry with the highest precedence wins, and its precedence is set as `q` + * so a compacted run keeps it. + */ +export async function* mergeRuns( + store: Store, + sessionId: string, + indexes: RunIndex[], + range?: RunRange | string, +): AsyncGenerator { + const spanBytes = Math.max(BLOCK_BYTES, Math.floor(PREFETCH_BUDGET / Math.max(1, indexes.length))) + const heap = new CursorHeap() + await Promise.all( + indexes.map(async (ix) => { + const c = new RunCursor(store, sessionId, ix, range, spanBytes) + if (await c.next()) heap.push(c) + }), + ) + while (heap.size > 0) { + const top = heap.pop()! + const winner = top.head! + const q = top.precedence + // Every other run holding the same key loses; advance them past it. + while (heap.size > 0 && compareRunKeys(heap.peek()!.head!, winner) === 0) { + const c = heap.pop()! + if (await c.next()) heap.push(c) + } + if (await top.next()) heap.push(top) + yield winner.q === q ? winner : { ...winner, q } + } +} + +/** Merge runs into one new run of the given tier (a compaction). */ +export async function compactRuns( + store: Store, + sessionId: string, + indexes: RunIndex[], + seq: number, + tier: number, +): Promise { + const w = new RunWriter(store, sessionId, seq, tier) + for await (const e of mergeRuns(store, sessionId, indexes)) { + w.add(e) + await w.drain() + } + return w.finish() +} + +/** The marks of runs, within a range, sorted and unique: split-key candidates. */ +export function runMarks(indexes: RunIndex[], range?: RunRange): string[] { + const out = new Set() + for (const ix of indexes) { + for (const b of blocksInRange(ix, range)) { + for (const m of b.marks ?? []) { + const sep = m.indexOf('\u0000') + if (!range || inRunRange(range, { t: m.slice(0, sep), k: m.slice(sep + 1) })) out.add(m) + } + } + } + return [...out].sort(compareUtf8) +} diff --git a/packages/server/src/push/session.ts b/packages/server/src/push/session.ts new file mode 100644 index 0000000..60b7e3d --- /dev/null +++ b/packages/server/src/push/session.ts @@ -0,0 +1,186 @@ +/** + * Push sessions. A session collects uploads against a base version and ends in + * one commit. Its inputs whose size the user controls (schemas, metadata, + * declared files) are an object in the platform's internal area; uploads are + * sorted runs (runs.ts). SQLite holds only the session row, counters and the run + * list. + */ +import { checkSchemaFull, hashSchema } from '@underlay/protocol' +import { and, asc, count, eq, gt, or, sql } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import type { Ports } from '../ports.js' +import { maybeCompact } from './compact.js' +import { readRunIndex, type RunIndex } from './runs.js' + +/** Idle timeout: pushed back by every upload. Runs live in storage, so this can be generous. */ +export const SESSION_TTL_MS = 60 * 60 * 1000 + +/** Sessions one user may have open or committing at once (v2-scale-review.md S3). */ +export const limits = { openSessions: 20 } + +/** Refused: the user already has `limits.openSessions` sessions in progress. */ +export class SessionCapError extends Error { + constructor(readonly open: number) { + super( + `You have ${open} push sessions in progress, the most allowed. Commit or abort one (DELETE …/push/:id), or let it expire, then try again.`, + ) + this.name = 'SessionCapError' + } +} + +export interface SessionInputs { + /** The full new type set: slug → schema. */ + schemas: Record> + /** The full new metadata. */ + metadata: Record | null + /** Declared files to add and remove. */ + files: { add: string[]; remove: string[] } +} + +export type SessionRow = typeof schema.pushSessions.$inferSelect + +const inputsKey = (id: string) => `sessions/${id}/inputs.json` + +export async function createSession( + ports: Ports, + row: Omit, + inputs: SessionInputs, +): Promise { + const [inProgress] = await ports.db + .select({ n: count() }) + .from(schema.pushSessions) + .where( + and( + eq(schema.pushSessions.userId, row.userId), + or( + eq(schema.pushSessions.status, 'committing'), + and( + eq(schema.pushSessions.status, 'open'), + gt(schema.pushSessions.expiresAt, new Date()), + ), + ), + ), + ) + if ((inProgress?.n ?? 0) >= limits.openSessions) throw new SessionCapError(inProgress!.n) + const id = crypto.randomUUID() + // Inputs first: a session row never points at missing inputs. + await ports.stores.internal.put(inputsKey(id), JSON.stringify(inputs), { + contentType: 'application/json', + }) + const [session] = await ports.db + .insert(schema.pushSessions) + .values({ ...row, id, expiresAt: new Date(Date.now() + SESSION_TTL_MS) }) + .returning() + return session! +} + +export async function loadInputs(ports: Ports, sessionId: string): Promise { + const obj = await ports.stores.internal.get(inputsKey(sessionId)) + if (!obj) throw new Error(`Session ${sessionId} has no inputs`) + return JSON.parse(await obj.text()) as SessionInputs +} + +export async function getSession(ports: Ports, sessionId: string): Promise { + const [s] = await ports.db + .select() + .from(schema.pushSessions) + .where(eq(schema.pushSessions.id, sessionId)) + .limit(1) + return s ?? null +} + +/** Claim the next run number for an open session and push back its expiry, atomically. */ +export async function nextRunSeq(ports: Ports, sessionId: string): Promise { + const [row] = await ports.db + .update(schema.pushSessions) + .set({ + runs: sql`${schema.pushSessions.runs} + 1`, + expiresAt: new Date(Date.now() + SESSION_TTL_MS), + }) + .where(and(eq(schema.pushSessions.id, sessionId), eq(schema.pushSessions.status, 'open'))) + .returning({ runs: schema.pushSessions.runs }) + return row?.runs ?? null +} + +export async function recordRun( + ports: Ports, + sessionId: string, + kind: 'records' | 'deletes', + index: RunIndex, + counters: { records?: number }, +): Promise { + const count = index.blocks.reduce((n, b) => n + b.count, 0) + await ports.db.batch([ + ports.db.insert(schema.pushRuns).values({ + sessionId, + seq: index.seq, + kind, + objectKey: `sessions/${sessionId}/runs/${index.seq}`, + count, + firstKey: index.blocks[0]?.first ?? '', + lastKey: index.blocks[index.blocks.length - 1]?.last ?? '', + tier: index.tier ?? 0, + }), + ports.db + .update(schema.pushSessions) + .set({ + recordsReceived: sql`${schema.pushSessions.recordsReceived} + ${counters.records ?? 0}`, + }) + .where(eq(schema.pushSessions.id, sessionId)), + ]) + await maybeCompact(ports, sessionId, kind, index.tier ?? 0) +} + +export async function sessionRuns( + ports: Ports, + sessionId: string, + kind?: 'records' | 'deletes', +): Promise { + const rows = await ports.db + .select() + .from(schema.pushRuns) + .where( + kind + ? and(eq(schema.pushRuns.sessionId, sessionId), eq(schema.pushRuns.kind, kind)) + : eq(schema.pushRuns.sessionId, sessionId), + ) + .orderBy(asc(schema.pushRuns.seq)) + return Promise.all(rows.map((r) => readRunIndex(ports.stores.internal, sessionId, r.seq))) +} + +/** Move a session between statuses only from an expected one (compare-and-swap). */ +export async function transition( + ports: Ports, + sessionId: string, + from: schema.SessionStatus, + to: schema.SessionStatus, + extra: Partial = {}, +): Promise { + const rows = await ports.db + .update(schema.pushSessions) + .set({ status: to, ...extra }) + .where(and(eq(schema.pushSessions.id, sessionId), eq(schema.pushSessions.status, from))) + .returning({ id: schema.pushSessions.id }) + return rows.length === 1 +} + +/** + * Why a session's type set (slug → schema) can't be accepted, or null: every + * schema passes the full acceptance check (`checkSchemaFull`: the bounds and v2 + * rules, the draft-07 meta-schema, `u`-flag patterns, `$ref`s that resolve). + */ +export function schemaSetError(schemas: unknown): string | null { + if (!schemas || typeof schemas !== 'object' || Array.isArray(schemas)) + return '"schemas" must be an object of type → schema' + for (const [slug, body] of Object.entries(schemas)) { + if (!body || typeof body !== 'object' || Array.isArray(body)) + return `Schema "${slug}" must be an object` + const err = checkSchemaFull(slug, body) + if (err) return err + } + return null +} + +export const schemaHashes = (schemas: Record) => + Object.fromEntries(Object.entries(schemas).map(([slug, s]) => [slug, hashSchema(s)])) diff --git a/packages/server/src/refs/log.ts b/packages/server/src/refs/log.ts new file mode 100644 index 0000000..af2f934 --- /dev/null +++ b/packages/server/src/refs/log.ts @@ -0,0 +1,635 @@ +/** + * The reference log: which collections and versions contain a record or file + * hash, in which set (edge-redesign.md, "Provenance: the reference log"). + * + * Index changes, not snapshots. A version's events are the diff between it and + * the previous version (+ added, - removed, both for an update or a set move), + * so writing costs O(changes). They're produced by jobs after publish — the + * plan accepts seconds of lag — which is also exactly the rebuild procedure. A + * large version is indexed in key-range units, each a run of its own, that go + * live together. + * A fork's first version writes no events; queries extend a parent's intervals + * into its forks. + * + * Storage is a size-tiered LSM of segments (segments.ts): each version adds a + * tier-0 run; when a tier has FAN_IN runs they merge into one run of the next + * tier, as hash-range parts run as jobs. Queries read every live run whose + * segments cover the hash: O(log n) runs, each a cached index check and usually + * no block read. + */ +import { + compareUtf8, + diffTrees, + entryAt, + fileTree, + recordTree, + RepoSource, +} from '@underlay/protocol' +import { and, asc, eq, gte, inArray, lte, sql } from 'drizzle-orm' + +import { chunks } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import type { Ports } from '../ports.js' +import { mergeRuns, type RunIndex, writeRun } from '../push/runs.js' +import { loadView } from '../versions/view.js' +import { + compareEvents, + deleteSegment, + eventBytes, + lookupSegment, + readSegment, + type RefEvent, + SegmentWriter, + type WrittenSegment, +} from './segments.js' + +export const FAN_IN = 8 +const SPILL_EVENTS = 100_000 + +export const indexConfig = { + /** Events one indexing job writes, about: a larger version is split into units. */ + unitEvents: 1_000_000, + /** Key ranges one type is split into, at most. */ + maxUnitsPerType: 1_000, +} +/** Events per compaction part, roughly: sizes the hash-range split. */ +const PART_EVENTS = 4_000_000 + +// --- Generating a version's events --------------------------------------------------- + +/** + * What one indexing unit covers: everything (a small version), or one key range + * of one set's record tree of one type, or one set's file tree. + */ +export interface IndexScope { + set: 'public' | 'private' + kind: 'r' | 'f' + /** Record type (kind 'r'). */ + slug?: string + /** Keys after `from` (null: from the start) through `to` (null: to the end). */ + from: string | null + to: string | null +} + +/** A version's events: the diff against the version before it, both sets, records and files. */ +export async function* versionEvents( + ports: Ports, + version: typeof schema.versions.$inferSelect, + scope?: IndexScope, +): AsyncGenerator { + const repo = await ports.stores.forCollection(version.collectionId) + const { now, before } = await viewsOf(ports, version) + const records = new RepoSource(recordTree, repo) + const files = new RepoSource(fileTree, repo) + const c = version.collectionId + const seq = version.seq + const after = scope?.from ?? undefined + const past = (key: string) => scope?.to != null && compareUtf8(key, scope.to) > 0 + for (const set of ['public', 'private'] as const) { + if (scope && scope.set !== set) continue + if (!scope || scope.kind === 'r') { + const slugs = new Set([ + ...now.types.map((t) => t.slug), + ...(before?.types.map((t) => t.slug) ?? []), + ]) + for (const slug of [...slugs].sort(compareUtf8)) { + if (scope && scope.slug !== slug) continue + const a = before?.types.find((t) => t.slug === slug)?.[set]?.root ?? null + const b = now.types.find((t) => t.slug === slug)?.[set]?.root ?? null + for await (const d of diffTrees(records, a, b, { after })) { + if (past(d.key)) break + if (d.before) yield [d.before.hash, 'r', c, set, seq, '-', slug, d.key] + if (d.after) yield [d.after.hash, 'r', c, set, seq, '+', slug, d.key] + } + } + } + if (!scope || scope.kind === 'f') { + const fa = (set === 'public' ? before?.public : before?.private)?.files.root ?? null + const fb = (set === 'public' ? now.public : now.private)?.files.root ?? null + for await (const d of diffTrees(files, fa, fb, { after })) { + if (past(d.key)) break + if (d.before) yield [d.key, 'f', c, set, seq, '-', '', ''] + if (d.after) yield [d.key, 'f', c, set, seq, '+', '', ''] + } + } + } +} + +/** A version's view and the one before it's, both sets. */ +async function viewsOf(ports: Ports, version: typeof schema.versions.$inferSelect) { + const repo = await ports.stores.forCollection(version.collectionId) + const [prev] = + version.seq > 1 + ? await ports.db + .select() + .from(schema.versions) + .where( + and( + eq(schema.versions.collectionId, version.collectionId), + eq(schema.versions.seq, version.seq - 1), + ), + ) + : [] + const now = await loadView(repo, version, true) + const before = prev ? await loadView(repo, prev, true) : null + return { now, before } +} + +/** About how many events a version makes, from its change counts (0 when unknown). */ +export function estimatedEvents(version: typeof schema.versions.$inferSelect): number { + const ch = version.changes + return ch ? ch.added + ch.removed + 2 * ch.updated : 0 +} + +/** Sort events by hash, spilling to sorted runs past SPILL_EVENTS (bounded memory). */ +async function* sortedEvents( + ports: Ports, + scratch: string, + unit: number, + events: AsyncIterable, +): AsyncGenerator { + let buf: RefEvent[] = [] + const runs: RunIndex[] = [] + const spill = async () => { + const entries = buf.map((e) => ({ + t: e[0], + k: JSON.stringify(e.slice(1)), + b: JSON.stringify(e), + })) + // Units of one version share the scratch area; their run numbers don't overlap. + const n = unit * 1_000_000 + runs.length + 1 + runs.push(await writeRun(ports.stores.internal, scratch, n, entries)) + buf = [] + } + for await (const e of events) { + buf.push(e) + if (buf.length >= SPILL_EVENTS) await spill() + } + if (runs.length === 0) { + yield* buf.sort(compareEvents) + return + } + if (buf.length) await spill() + // Run keys are unique per event, so the merge keeps every event. + for await (const r of mergeRuns(ports.stores.internal, scratch, runs)) + yield JSON.parse(r.b!) as RefEvent +} + +const segmentRows = ( + segs: WrittenSegment[], + runId: string, + tier: number, + state: 'live' | 'pending', +) => + segs.map((s) => ({ + id: s.id, + runId, + tier, + firstHash: s.firstHash, + lastHash: s.lastHash, + count: s.count, + bytes: s.bytes, + state, + })) + +/** + * Index one version. A version of up to UNIT_EVENTS events is one unit, done + * here. A larger one is split into key-range units (per set and type, at ranks + * of the larger tree, so each holds about UNIT_EVENTS of the changes when they + * spread evenly), each a `refs.indexUnit` job on the bulk queue. + */ +export async function indexVersion(ports: Ports, versionId: string): Promise { + const { db } = ports + const [version] = await db.select().from(schema.versions).where(eq(schema.versions.id, versionId)) + if (!version || version.refsIndexed) return + const [fork] = + version.seq === 1 + ? await db + .select() + .from(schema.forks) + .where(eq(schema.forks.childCollectionId, version.collectionId)) + : [] + // A fork's first version writes no events. + if (fork) return finishIndex(ports, version, 0) + const est = estimatedEvents(version) + if (est <= indexConfig.unitEvents) return indexUnit(ports, version, 0, 1, null) + const units = await planUnits(ports, version, Math.ceil(est / indexConfig.unitEvents)) + await ports.jobs.enqueueBatch( + units.map((scope, unit) => ({ + type: 'refs.indexUnit', + versionId, + unit, + units: units.length, + scope: JSON.stringify(scope), + })), + ) +} + +/** Key ranges for a large version: a type's share of `splits`, cut at ranks of its tree. */ +async function planUnits( + ports: Ports, + version: typeof schema.versions.$inferSelect, + splits: number, +): Promise { + const repo = await ports.stores.forCollection(version.collectionId) + const { now, before } = await viewsOf(ports, version) + const records = new RepoSource(recordTree, repo) + const total = Math.max(1, version.recordCount) + const units: IndexScope[] = [] + for (const set of ['public', 'private'] as const) { + const slugs = new Set([ + ...now.types.map((t) => t.slug), + ...(before?.types.map((t) => t.slug) ?? []), + ]) + for (const slug of [...slugs].sort(compareUtf8)) { + const a = before?.types.find((t) => t.slug === slug)?.[set] ?? null + const b = now.types.find((t) => t.slug === slug)?.[set] ?? null + if ((a?.root ?? null) === (b?.root ?? null)) continue + const big = (b?.count ?? 0) >= (a?.count ?? 0) ? b : a + const n = Math.min( + indexConfig.maxUnitsPerType, + Math.max(1, Math.round(((big?.count ?? 0) * splits) / total)), + ) + let from: string | null = null + for (let i = 1; i < n; i++) { + const at = await entryAt(records, big!.root, Math.floor((i * big!.count) / n)) + if (!at || (from !== null && compareUtf8(at.key, from) <= 0)) continue + units.push({ set, kind: 'r', slug, from, to: at.key }) + from = at.key + } + units.push({ set, kind: 'r', slug, from, to: null }) + } + const fa = (set === 'public' ? before?.public : before?.private)?.files.root ?? null + const fb = (set === 'public' ? now.public : now.private)?.files.root ?? null + if (fa !== fb) units.push({ set, kind: 'f', from: null, to: null }) + } + return units +} + +/** + * One unit: its events, sorted, as a pending run of its own; when every unit of + * the version is in, they go live together (finishIndex). Segment ids derive + * from the version and unit, so a retried job rewrites the same objects. + */ +export async function indexUnit( + ports: Ports, + version: typeof schema.versions.$inferSelect, + unit: number, + units: number, + scope: IndexScope | null, +): Promise { + const { db } = ports + const runId = units === 1 ? `v-${version.id}` : `v-${version.id}-u${unit}` + const writer = new SegmentWriter(ports.stores.internal, (n) => `${runId}-${n}`) + let events = 0 + let bytes = 0 + const scratch = `refs-${version.id}` + const sorted = sortedEvents( + ports, + scratch, + unit, + versionEvents(ports, version, scope ?? undefined), + ) + for await (const e of sorted) { + await writer.add(e) + events++ + bytes += eventBytes(e) + } + const segs = await writer.finish() + await db.batch([ + ...segmentRows(segs, runId, 0, 'pending').map((r) => + db.insert(schema.refSegments).values(r).onConflictDoNothing(), + ), + db + .insert(schema.refIndexUnits) + .values({ versionId: version.id, unit, events, bytes }) + .onConflictDoNothing(), + ] as unknown as Parameters[0]) + const [done] = (await db.all(sql` + SELECT count(*) AS n, coalesce(sum(events), 0) AS events, coalesce(sum(bytes), 0) AS bytes + FROM ${schema.refIndexUnits} WHERE version_id = ${version.id} + `)) as { n: number; events: number; bytes: number }[] + if (Number(done?.n) === units) + await finishIndex(ports, version, Number(done!.events), Number(done!.bytes)) +} + +/** + * Put a version's runs live, set its flag and counts, and add them to the + * collection's billing counters, in one batch that only takes effect while the + * version is still unindexed. + */ +async function finishIndex( + ports: Ports, + version: typeof schema.versions.$inferSelect, + events: number, + bytes = 0, +): Promise { + const { db } = ports + const unindexed = sql`(SELECT ${schema.versions.refsIndexed} FROM ${schema.versions} WHERE ${schema.versions.id} = ${version.id}) = 0` + await db.batch([ + db + .update(schema.refSegments) + .set({ state: 'live' }) + .where( + and( + sql`${schema.refSegments.runId} LIKE ${`v-${version.id}%`}`, + eq(schema.refSegments.state, 'pending'), + unindexed, + ), + ), + db + .update(schema.collections) + .set({ + refEvents: sql`${schema.collections.refEvents} + ${events}`, + refBytes: sql`${schema.collections.refBytes} + ${bytes}`, + }) + .where(and(eq(schema.collections.id, version.collectionId), unindexed)), + db + .update(schema.versions) + .set({ refsIndexed: true, refEvents: events, refBytes: bytes }) + .where(eq(schema.versions.id, version.id)), + db.delete(schema.refIndexUnits).where(eq(schema.refIndexUnits.versionId, version.id)), + ] as unknown as Parameters[0]) + await ports.jobs.enqueue({ type: 'refs.compact' }) +} + +// --- Compaction --------------------------------------------------------------------- + +/** If some tier has FAN_IN live runs, start merging its oldest FAN_IN into one run of the next tier. */ +export async function planCompaction(ports: Ports): Promise { + const { db } = ports + const running = await db + .select() + .from(schema.refCompactions) + .where(eq(schema.refCompactions.status, 'running')) + .limit(1) + if (running.length) return + const runs = (await db.all(sql` + SELECT run_id, tier, sum(count) AS events, min(created_at) AS created + FROM ${schema.refSegments} WHERE state = 'live' + GROUP BY run_id, tier ORDER BY tier, created + `)) as { run_id: string; tier: number; events: number; created: number }[] + const byTier = new Map() + for (const r of runs) byTier.set(r.tier, [...(byTier.get(r.tier) ?? []), r]) + for (const [tier, list] of [...byTier].sort((a, b) => a[0] - b[0])) { + if (list.length < FAN_IN) continue + const inputs = list.slice(0, FAN_IN) + const total = inputs.reduce((n, r) => n + r.events, 0) + // Hash-range parts by hex prefix: 16^L parts of roughly PART_EVENTS each. + let prefixLength = 0 + while (16 ** prefixLength * PART_EVENTS < total && prefixLength < 4) prefixLength++ + const [comp] = await db + .insert(schema.refCompactions) + .values({ + tier, + inputRuns: inputs.map((r) => r.run_id), + outputRun: `c-${crypto.randomUUID()}`, + parts: 16 ** prefixLength, + prefixLength, + }) + .returning() + await ports.jobs.enqueueBatch( + Array.from({ length: comp!.parts }, (_, part) => ({ + type: 'refs.compactPart', + compactionId: comp!.id, + part, + })), + ) + return + } +} + +const partRange = (part: number, prefixLength: number) => { + if (prefixLength === 0) return { from: '', to: 'g' } + const from = part.toString(16).padStart(prefixLength, '0') + const to = + part + 1 === 16 ** prefixLength ? 'g' : (part + 1).toString(16).padStart(prefixLength, '0') + return { from, to } +} + +/** Merge one hash range of a compaction's input runs into pending segments of its output run. */ +export async function compactPart(ports: Ports, compactionId: string, part: number): Promise { + const { db } = ports + const [comp] = await db + .select() + .from(schema.refCompactions) + .where(eq(schema.refCompactions.id, compactionId)) + if (!comp || comp.status !== 'running') return + const range = partRange(part, comp.prefixLength) + const inputs = await db + .select() + .from(schema.refSegments) + .where( + and( + inArray(schema.refSegments.runId, comp.inputRuns), + lte(schema.refSegments.firstHash, range.to), + gte(schema.refSegments.lastHash, range.from), + ), + ) + .orderBy(asc(schema.refSegments.firstHash)) + // Each input run, in hash order, as one stream; then a k-way merge by event order. + const streams = comp.inputRuns.map((run) => + (async function* () { + for (const seg of inputs.filter((s) => s.runId === run)) + yield* readSegment(ports.stores.internal, seg.id, range) + })(), + ) + const heads = await Promise.all(streams.map(async (s) => ({ s, h: await s.next() }))) + const writer = new SegmentWriter(ports.stores.internal, (n) => `${comp.outputRun}-${part}-${n}`) + // Events of deleted collections (tombstoned, with no row) go. + const gone = await deletedCollections(ports) + for (;;) { + let best: (typeof heads)[number] | null = null + for (const x of heads) + if (!x.h.done && (!best || compareEvents(x.h.value, best.h.value as RefEvent) < 0)) best = x + if (!best) break + const event = best.h.value as RefEvent + if (!gone.has(event[2])) await writer.add(event) + best.h = await best.s.next() + } + const segs = await writer.finish() + await db.batch([ + ...segmentRows(segs, comp.outputRun, comp.tier + 1, 'pending').map((r) => + db.insert(schema.refSegments).values(r).onConflictDoNothing(), + ), + db.insert(schema.refCompactionParts).values({ compactionId, part }).onConflictDoNothing(), + ] as unknown as Parameters[0]) + const [{ done } = { done: 0 }] = (await db.all( + sql`SELECT count(*) AS done FROM ${schema.refCompactionParts} WHERE compaction_id = ${compactionId}`, + )) as { done: number }[] + if (done === comp.parts) await ports.jobs.enqueue({ type: 'refs.finishCompaction', compactionId }) +} + +/** Ids of tombstoned collections that no collection row holds. */ +async function deletedCollections(ports: Ports): Promise> { + const rows = (await ports.db.all(sql` + SELECT t.collection_id AS id FROM ${schema.collectionTombstones} t + WHERE NOT EXISTS (SELECT 1 FROM ${schema.collections} c WHERE c.id = t.collection_id) + `)) as { id: string }[] + return new Set(rows.map((r) => r.id)) +} + +/** Swap a finished compaction in atomically, then delete the inputs' objects. */ +export async function finishCompaction(ports: Ports, compactionId: string): Promise { + const { db } = ports + const [comp] = await db + .select() + .from(schema.refCompactions) + .where(eq(schema.refCompactions.id, compactionId)) + if (!comp || comp.status !== 'running') return + const old = await db + .select({ id: schema.refSegments.id }) + .from(schema.refSegments) + .where(inArray(schema.refSegments.runId, comp.inputRuns)) + await db.batch([ + db + .update(schema.refSegments) + .set({ state: 'live' }) + .where(eq(schema.refSegments.runId, comp.outputRun)), + db + .update(schema.refSegments) + .set({ state: 'retired' }) + .where(inArray(schema.refSegments.runId, comp.inputRuns)), + db + .update(schema.refCompactions) + .set({ status: 'done' }) + .where(eq(schema.refCompactions.id, compactionId)), + ]) + for (const s of old) await deleteSegment(ports.stores.internal, s.id) + await db.delete(schema.refSegments).where(inArray(schema.refSegments.runId, comp.inputRuns)) + await ports.jobs.enqueue({ type: 'refs.compact' }) +} + +registerJob('refs.index', async (job, ports) => indexVersion(ports, String(job.versionId))) +registerJob('refs.indexUnit', async (job, ports) => { + const [version] = await ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, String(job.versionId))) + if (!version || version.refsIndexed) return + await indexUnit( + ports, + version, + Number(job.unit), + Number(job.units), + JSON.parse(String(job.scope)) as IndexScope, + ) +}) +registerJob('refs.compact', async (_job, ports) => planCompaction(ports)) +registerJob('refs.compactPart', async (job, ports) => + compactPart(ports, String(job.compactionId), Number(job.part)), +) +registerJob('refs.finishCompaction', async (job, ports) => + finishCompaction(ports, String(job.compactionId)), +) + +// --- Queries ------------------------------------------------------------------------ + +/** Every event for a hash across live segments. */ +export async function eventsFor(ports: Ports, hash: string): Promise { + const segs = await ports.db + .select({ id: schema.refSegments.id }) + .from(schema.refSegments) + .where( + and( + eq(schema.refSegments.state, 'live'), + lte(schema.refSegments.firstHash, hash), + gte(schema.refSegments.lastHash, hash), + ), + ) + const found = await Promise.all( + segs.map((s) => lookupSegment(ports.stores.internal, ports.cache, s.id, hash)), + ) + return found.flat().sort(compareEvents) +} + +export interface Presence { + collectionId: string + set: 'public' | 'private' + kind: 'r' | 'f' + type: string + id: string + /** First seq containing it, and the first seq that no longer does (null: still present). */ + from: number + to: number | null +} + +/** + * Fold events into presence intervals per (collection, set, type, id), and + * extend them into forks: a fork's first version contains whatever its parent's + * version at the fork point did, until the fork removes it. + */ +export async function presenceOf(ports: Ports, hash: string): Promise { + const events = await eventsFor(ports, hash) + const open = new Map() + const out: Presence[] = [] + // Removals with no addition in the same collection: a fork dropping something it inherited. + const orphanRemovals = new Map() + for (const e of events) { + const [, kind, collectionId, set, seq, op, type, id] = e + const key = `${collectionId}\u0000${set}\u0000${type}\u0000${id}` + if (op === '+') { + if (!open.has(key)) open.set(key, { collectionId, set, kind, type, id, from: seq, to: null }) + } else { + const p = open.get(key) + if (p) { + p.to = seq + out.push(p) + open.delete(key) + } else if (!orphanRemovals.has(key)) { + orphanRemovals.set(key, seq) + } + } + } + out.push(...open.values()) + // Forks: children whose parent contained it at the fork point, one level of + // the fork graph per query (D1 runs at most 1,000 queries per invocation). + let frontier = [...out] + while (frontier.length) { + const forks: (typeof schema.forks.$inferSelect)[] = [] + for (const part of chunks([...new Set(frontier.map((p) => p.collectionId))])) { + forks.push( + ...(await ports.db + .select() + .from(schema.forks) + .where(inArray(schema.forks.parentCollectionId, part))), + ) + } + const next: Presence[] = [] + for (const p of frontier) { + for (const f of forks) { + if (f.parentCollectionId !== p.collectionId) continue + if (f.parentSeq < p.from || (p.to !== null && f.parentSeq >= p.to)) continue + // A fork carries only the sets it was made with. + if (p.set === 'private' && f.sets !== 'public+private') continue + // Already inherited. The fork's own presences start after its first version + // (which writes no events), so a record it removed and added back keeps both. + if ( + out.some( + (q) => + q.collectionId === f.childCollectionId && + q.from === 1 && + q.type === p.type && + q.id === p.id && + q.set === p.set, + ) + ) + continue + const removed = orphanRemovals.get( + `${f.childCollectionId}\u0000${p.set}\u0000${p.type}\u0000${p.id}`, + ) + const child: Presence = { + ...p, + collectionId: f.childCollectionId, + from: 1, + to: removed ?? null, + } + out.push(child) + next.push(child) + } + } + frontier = next + } + return out +} diff --git a/packages/server/src/refs/segments.ts b/packages/server/src/refs/segments.ts new file mode 100644 index 0000000..20ae123 --- /dev/null +++ b/packages/server/src/refs/segments.ts @@ -0,0 +1,240 @@ +/** + * Reference-log segments: immutable, sorted by hash, in the platform's internal + * area (never mirrored). + * + * refs/seg/.dat gzip blocks of NDJSON events, concatenated (~1,024 events each) + * refs/seg/.idx {count, bytes, blocks: [[first, last, offset, length]], m, k, bloom} + * + * The index is small (about 1.3 MB of filter at the 256k-event cap) and + * immutable, so it's cached; a lookup that passes the Bloom filter reads one + * block by range. + */ +import { gunzipText, gzip, splitLines } from '@underlay/protocol' + +import type { Store, Cache } from '../ports.js' + +/** [hash, kind, collectionId, set, seq, op, type, id]. kind: r(ecord) | f(ile). op: + | -. */ +export type RefEvent = [ + string, + 'r' | 'f', + string, + 'public' | 'private', + number, + '+' | '-', + string, + string, +] + +export const SEGMENT_MAX_EVENTS = 262_144 +const BLOCK_EVENTS = 1024 +const BLOOM_BITS_PER_KEY = 10 +const BLOOM_K = 7 + +const enc = new TextEncoder() +/** Billable bytes: the event's canonical encoding (a pure function of the event). */ +export const eventBytes = (e: RefEvent) => enc.encode(JSON.stringify(e)).byteLength + +export const compareEvents = (a: RefEvent, b: RefEvent) => + a[0] < b[0] + ? -1 + : a[0] > b[0] + ? 1 + : a[2] < b[2] + ? -1 + : a[2] > b[2] + ? 1 + : a[4] - b[4] || (a[5] < b[5] ? -1 : a[5] > b[5] ? 1 : 0) + +export interface SegmentIndex { + count: number + bytes: number + blocks: [string, string, number, number][] + m: number + k: number + bloom: string +} + +const keyOf = (id: string, ext: 'dat' | 'idx') => `refs/seg/${id}.${ext}` + +function bloomPositions(hash: string, m: number, k: number): number[] { + const h1 = parseInt(hash.slice(0, 8), 16) + // `| 1` alone would make values ≥ 2^31 negative (int32); keep them unsigned. + const h2 = (parseInt(hash.slice(8, 16), 16) | 1) >>> 0 + return Array.from({ length: k }, (_, i) => (h1 + i * h2) % m) +} + +export function bloomHas(idx: SegmentIndex, bits: Uint8Array, hash: string): boolean { + return bloomPositions(hash, idx.m, idx.k).every((p) => (bits[p >> 3]! & (1 << (p & 7))) !== 0) +} + +export interface WrittenSegment { + id: string + firstHash: string + lastHash: string + count: number + bytes: number +} + +/** Writes sorted events as segments of at most SEGMENT_MAX_EVENTS. */ +export class SegmentWriter { + readonly segments: WrittenSegment[] = [] + #blocks: Uint8Array[] = [] + #blockIndex: [string, string, number, number][] = [] + #offset = 0 + #lines: string[] = [] + #first: string | null = null + #last: string | null = null + #blockFirst: string | null = null + #count = 0 + #bytes = 0 + #prefixes: number[] = [] + #n = 0 + + constructor( + readonly store: Store, + readonly idFor: (n: number) => string, + ) {} + + async add(e: RefEvent): Promise { + if (this.#last !== null && e[0] < this.#last) throw new Error('Reference events out of order') + const line = JSON.stringify(e) + this.#first ??= e[0] + this.#blockFirst ??= e[0] + this.#last = e[0] + this.#lines.push(line) + this.#count++ + this.#bytes += enc.encode(line).byteLength + this.#prefixes.push(parseInt(e[0].slice(0, 8), 16), parseInt(e[0].slice(8, 16), 16)) + if (this.#lines.length >= BLOCK_EVENTS) await this.#flushBlock() + if (this.#count >= SEGMENT_MAX_EVENTS) await this.#finishSegment() + } + + async #flushBlock() { + if (this.#lines.length === 0) return + const gz = await gzip(this.#lines.join('\n') + '\n') + this.#blockIndex.push([this.#blockFirst!, this.#last!, this.#offset, gz.byteLength]) + this.#blocks.push(gz) + this.#offset += gz.byteLength + this.#lines = [] + this.#blockFirst = null + } + + async #finishSegment() { + await this.#flushBlock() + if (this.#count === 0) return + const id = this.idFor(this.#n++) + const m = Math.max(64, this.#count * BLOOM_BITS_PER_KEY) + const bits = new Uint8Array(Math.ceil(m / 8)) + for (let i = 0; i < this.#prefixes.length; i += 2) { + const h1 = this.#prefixes[i]! + const h2 = (this.#prefixes[i + 1]! | 1) >>> 0 + for (let j = 0; j < BLOOM_K; j++) { + const p = (h1 + j * h2) % m + bits[p >> 3]! |= 1 << (p & 7) + } + } + const dat = new Uint8Array(this.#offset) + let off = 0 + for (const b of this.#blocks) { + dat.set(b, off) + off += b.byteLength + } + const idx: SegmentIndex = { + count: this.#count, + bytes: this.#bytes, + blocks: this.#blockIndex, + m, + k: BLOOM_K, + bloom: Buffer.from(bits).toString('base64'), + } + await this.store.put(keyOf(id, 'dat'), dat, { contentType: 'application/gzip' }) + await this.store.put(keyOf(id, 'idx'), JSON.stringify(idx), { contentType: 'application/json' }) + this.segments.push({ + id, + firstHash: this.#first!, + lastHash: this.#last!, + count: this.#count, + bytes: this.#bytes, + }) + this.#blocks = [] + this.#blockIndex = [] + this.#offset = 0 + this.#first = null + this.#count = 0 + this.#bytes = 0 + this.#prefixes = [] + } + + async finish(): Promise { + await this.#finishSegment() + return this.segments + } +} + +const idxCache = new Map() + +async function loadIndex(store: Store, cache: Cache, id: string) { + const hit = idxCache.get(id) + if (hit) return hit + const key = `refidx/${id}` + let bytes = await cache.get(key) + if (!bytes) { + const obj = await store.get(keyOf(id, 'idx')) + if (!obj) throw new Error(`Missing reference segment index ${id}`) + bytes = await obj.bytes() + await cache.put(key, bytes) + } + const idx = JSON.parse(new TextDecoder().decode(bytes)) as SegmentIndex + const entry = { idx, bits: new Uint8Array(Buffer.from(idx.bloom, 'base64')) } + if (idxCache.size > 256) idxCache.delete(idxCache.keys().next().value!) + idxCache.set(id, entry) + return entry +} + +/** Events for one hash in one segment. */ +export async function lookupSegment( + store: Store, + cache: Cache, + id: string, + hash: string, +): Promise { + const { idx, bits } = await loadIndex(store, cache, id) + if (!bloomHas(idx, bits, hash)) return [] + const out: RefEvent[] = [] + for (const [first, last, offset, length] of idx.blocks) { + if (hash < first || hash > last) continue + const obj = await store.get(keyOf(id, 'dat'), { offset, length }) + if (!obj) throw new Error(`Missing reference segment ${id}`) + for (const line of splitLines(await gunzipText(await obj.bytes()))) { + const e = JSON.parse(line) as RefEvent + if (e[0] === hash) out.push(e) + } + } + return out +} + +/** All events of a segment in order, a block at a time (for compaction). */ +export async function* readSegment( + store: Store, + id: string, + range?: { from: string; to: string }, +): AsyncGenerator { + const obj = await store.get(keyOf(id, 'idx')) + if (!obj) throw new Error(`Missing reference segment index ${id}`) + const idx = JSON.parse(await obj.text()) as SegmentIndex + for (const [first, last, offset, length] of idx.blocks) { + if (range && (last < range.from || first >= range.to)) continue + const block = await store.get(keyOf(id, 'dat'), { offset, length }) + if (!block) throw new Error(`Missing reference segment ${id}`) + for (const line of splitLines(await gunzipText(await block.bytes()))) { + const e = JSON.parse(line) as RefEvent + if (range && (e[0] < range.from || e[0] >= range.to)) continue + yield e + } + } +} + +export async function deleteSegment(store: Store, id: string): Promise { + await store.delete(keyOf(id, 'dat')) + await store.delete(keyOf(id, 'idx')) +} diff --git a/packages/server/src/stores.ts b/packages/server/src/stores.ts new file mode 100644 index 0000000..f5f4505 --- /dev/null +++ b/packages/server/src/stores.ts @@ -0,0 +1,97 @@ +/** + * Resolve repositories from placements (edge-redesign.md, "Placements"). + * + * Today every collection's primary is the platform location, but nothing below + * assumes it: the primary is looked up per collection, and a location is turned + * into a Repo from its row. Customer (s3) locations become readable once + * credential encryption lands with bucket mirrors (phase 11). + */ +import { + type Cache, + PrefixedStore, + type PresigningStore, + Repo, + sharedLru, + type Store, +} from '@underlay/protocol' +import { and, eq } from 'drizzle-orm' + +import * as schema from './db/schema.js' +import type { Db, Stores } from './ports.js' + +export interface PlatformStorage { + /** The deployment's own bucket: repositories and internal objects. */ + bucket: Store + /** + * The same bucket through a store that presigns, for file bytes (direct + * uploads and downloads). On Workers `bucket` can be the R2 binding, which + * can't presign, and this the S3 API. Defaults to `bucket` when it presigns. + */ + files?: PresigningStore + /** Key prefix for repositories in it ('' for the bucket root). */ + repoPrefix: string + /** Key prefix for platform-internal objects. */ + internalPrefix: string +} + +/** Primary placements change rarely; remember them briefly per isolate. */ +const PRIMARY_TTL_MS = 60_000 +const primaryCache = new Map() + +export function createStores(db: Db, cache: Cache, platform: PlatformStorage): Stores { + const repos = new Map() + const files = platform.files ?? presigning(platform.bucket) + + const forLocation = async (locationId: string): Promise => { + const known = repos.get(locationId) + if (known) return known + const [loc] = await db + .select() + .from(schema.storageLocations) + .where(eq(schema.storageLocations.id, locationId)) + .limit(1) + if (!loc) throw new Error(`Unknown storage location ${locationId}`) + if (loc.kind !== 'platform') { + throw new Error(`Storage location ${locationId} (${loc.kind}) is not readable yet`) + } + const prefix = [platform.repoPrefix, loc.prefix].filter(Boolean).join('/') + const repo = new Repo(new PrefixedStore(platform.bucket, prefix), { + cache, + scope: `loc:${locationId}`, + trusted: true, + lru: sharedLru(), + }) + repos.set(locationId, repo) + return repo + } + + return { + forLocation, + async forCollection(collectionId: string): Promise { + const hit = primaryCache.get(collectionId) + if (hit && Date.now() - hit.at < PRIMARY_TTL_MS) return forLocation(hit.locationId) + const [row] = await db + .select({ locationId: schema.placements.locationId }) + .from(schema.placements) + .where( + and( + eq(schema.placements.collectionId, collectionId), + eq(schema.placements.role, 'primary'), + ), + ) + .limit(1) + if (!row) throw new Error(`Collection ${collectionId} has no primary placement`) + primaryCache.set(collectionId, { locationId: row.locationId, at: Date.now() }) + return forLocation(row.locationId) + }, + internal: new PrefixedStore(platform.bucket, platform.internalPrefix), + fileBytes: files, + canonicalFileKey: (hash) => [platform.repoPrefix, 'files', hash].filter(Boolean).join('/'), + stagingKey: (id) => [platform.internalPrefix, 'uploads', id].filter(Boolean).join('/'), + } +} + +function presigning(store: Store): PresigningStore { + if (!store.presigner) throw new Error('The platform bucket store cannot presign; pass `files`') + return store as PresigningStore +} diff --git a/packages/server/src/versions/collection-info.ts b/packages/server/src/versions/collection-info.ts new file mode 100644 index 0000000..76f6245 --- /dev/null +++ b/packages/server/src/versions/collection-info.ts @@ -0,0 +1,132 @@ +/** + * collection.json (spec 11.1): what a repository says about its collection + * without a server: the owner, names, description, visibility, ARK and signing + * keys. A commit writes it before each log entry; anything that changes a member + * without a version (a rename, visibility, a transfer, ARK settings, the owner's + * name or slug) queues `collection.info`, which rewrites it and the copy on every + * mirror that already holds a version. + * + * collection.info rewrite one collection's collection.json, then its mirrors' + * collection.info.org queue collection.info for each of an organization's collections + * collection.info.backfill queue collection.info for every collection, once, to rewrite + * files earlier code wrote (staging, next.underlay.org's load) + */ +import { + type CollectionInfo, + jcs, + type Repo, + readCollectionInfo, + type Signer, + writeCollectionInfo, +} from '@underlay/protocol' +import { and, asc, eq, gt, isNull } from 'drizzle-orm' + +import { collectionArk } from '../api/ark.js' +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import { mirrorCollectionInfo } from '../locations/mirror.js' +import type { Ports } from '../ports.js' + +/** + * Keep `collection.json` current. With a signer (a commit about to sign an + * entry) its key is listed; otherwise the keys stay as they are, since a key + * stays listed once it has signed (old entries still verify). Without a signer + * and without an existing file, nothing is written: the collection has no log + * yet, and its first commit writes the file. Returns the file and whether it + * changed, or null when nothing was written. + */ +export async function publishCollectionInfo( + ports: Ports, + repo: Repo, + collectionId: string, + signer?: Signer, +): Promise<{ info: CollectionInfo; changed: boolean } | null> { + const [row] = await ports.db + .select({ c: schema.collections, owner: schema.organization }) + .from(schema.collections) + .innerJoin(schema.organization, eq(schema.organization.id, schema.collections.organizationId)) + .where(eq(schema.collections.id, collectionId)) + .limit(1) + if (!row) { + if (signer) throw new Error(`Collection ${collectionId} not found`) + return null + } + const existing = await readCollectionInfo(repo, collectionId) + if (!existing && !signer) return null + const keys = signer + ? [...(existing?.keys ?? []).filter((k) => k.id !== signer.keyId), signer.publicKey] + : existing!.keys + // The ARK itself, `ark:NAAN/name`, not the resolver URL the API serves. + const arkUrl = (await collectionArk(ports.db, collectionId, row.owner))?.() + const info: CollectionInfo = { + id: collectionId, + owner: { id: row.owner.id, did: null, handle: row.owner.slug, name: row.owner.name }, + slug: row.c.slug, + name: row.c.name, + description: row.c.summary?.description ?? null, + visibility: row.c.public ? 'public' : 'private', + ark: arkUrl ? arkUrl.slice(arkUrl.indexOf('ark:')) : null, + keys, + } + if (existing && jcs(existing) === jcs(info)) return { info, changed: false } + await writeCollectionInfo(repo, info) + return { info, changed: true } +} + +/** Queue a rewrite of these collections' collection.json (after a change outside a version). */ +export async function queueCollectionInfo(ports: Ports, collectionIds: string[]): Promise { + if (collectionIds.length === 0) return + await ports.jobs.enqueueBatch( + collectionIds.map((collectionId) => ({ type: 'collection.info', collectionId })), + ) +} + +// Every collection an organization owns, after its name or slug changed. Callers +// enqueue the job rather than import this module: it imports the ARK routes. +registerJob('collection.info.org', async (job, ports) => { + const rows = await ports.db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where(eq(schema.collections.organizationId, String(job.organizationId))) + await queueCollectionInfo( + ports, + rows.map((r) => r.id), + ) +}) + +registerJob('collection.info', async (job, ports) => { + const collectionId = String(job.collectionId) + const repo = await ports.stores.forCollection(collectionId) + const out = await publishCollectionInfo(ports, repo, collectionId) + if (out?.changed) await mirrorCollectionInfo(ports, collectionId, out.info) +}) + +/** Collections per backfill job, before it queues the next page. */ +const BACKFILL_PAGE = 500 + +/** The internal-area marker that says the backfill has been queued. */ +export const INFO_BACKFILL_MARKER = 'collection-info/backfill-2026-10-05' + +/** Queue the backfill once (the maintenance sweep calls this). */ +export async function queueInfoBackfill(ports: Ports): Promise { + if (await ports.stores.internal.head(INFO_BACKFILL_MARKER)) return + await ports.stores.internal.put(INFO_BACKFILL_MARKER, new Date().toISOString()) + await ports.jobs.enqueue({ type: 'collection.info.backfill', after: '' }) +} + +registerJob('collection.info.backfill', async (job, ports) => { + const after = String(job.after ?? '') + const rows = await ports.db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where(and(gt(schema.collections.id, after), isNull(schema.collections.deletedAt))) + .orderBy(asc(schema.collections.id)) + .limit(BACKFILL_PAGE) + await queueCollectionInfo( + ports, + rows.map((r) => r.id), + ) + if (rows.length === BACKFILL_PAGE) { + await ports.jobs.enqueue({ type: 'collection.info.backfill', after: rows.at(-1)!.id }) + } +}) diff --git a/packages/server/src/versions/commit.ts b/packages/server/src/versions/commit.ts new file mode 100644 index 0000000..00f1a55 --- /dev/null +++ b/packages/server/src/versions/commit.ts @@ -0,0 +1,343 @@ +/** + * The commit engine: turn a base version plus sorted changes into a new + * version, and publish it. + * + * 1. per (set, type): merge the base tree with that set's changes + * (unchanged trees are reused as they are; type flips move whole trees); + * 2. file sets from reference-count deltas (file-refs.ts); + * 3. set objects, the private set object, the root, the version hash; + * 4. CAS publish (publish.ts), then the signed log entry and head.json. + * + * Every object is written before the publish, keyed by content, so a crash or a + * lost race leaves only unreferenced objects. The publish holds only under the + * storage fence the write phase began with (cleanup/fence.ts); when a deletion + * window opened since, commitVersion throws FenceError and the caller, which + * owns the change streams, runs the commit again (`fenced`). Cost is O(changes) except where + * the work is inherently per-record: revalidating a type whose schema changed, + * removing a type, and moving a type between sets when both sets hold records. + * + * This runs a whole commit in one call, which suits small and medium pushes. + * Large ones split the per-(set, type) merges into key-range units run as jobs + * (edge-redesign.md, Commit step 2); the merge code is the same. + */ +import { + appendLog, + type BuildTypeInput, + buildVersion, + bumpType, + compareUtf8, + deriveSemver, + FileRefDelta, + fileTree, + mergeTree, + parseSemver, + readHead, + type Repo, + RepoSink, + RepoSource, + setRecordTotals, + signEntry, + type TreeSummary, +} from '@underlay/protocol' +import { eq } from 'drizzle-orm' + +import { FenceError, writeFence } from '../cleanup/fence.js' +import * as schema from '../db/schema.js' +import type { Ports } from '../ports.js' +import { publishCollectionInfo } from './collection-info.js' +import { collectionFileSizes, fileSizes } from './file-refs.js' +import { publishVersion, type SchemaUsageChange } from './publish.js' + +export type { ChangeSource } from '@underlay/protocol' +export type TypeInput = BuildTypeInput + +export interface BaseVersion { + id: string + seq: number + semver: string + hash: string + publicRefsRoot: string | null + privateRefsRoot: string | null +} + +export interface CommitInput { + collectionId: string + /** + * The storage fence epoch read when this commit's write phase began (before a + * parallel commit's units, or around a retry loop). Read here when absent. + */ + fence?: number + base: BaseVersion | null + /** The full new type set: types in the base but not here are removed. */ + types: TypeInput[] + /** The full new metadata. */ + metadata: Record | null + /** Changes to declared (possibly unreferenced) files. */ + declaredFiles?: { add: string[]; remove: string[] } + message?: string | null + pushedBy?: string | null + appId?: string | null + actorId?: string | null + /** The push session this commit finishes; recorded on the version. */ + pushSessionId?: string | null + /** + * Trees already merged by a parallel commit (push/parallel.ts), per type, with + * the file reference deltas and change counts of the units that built them. + * The types' change sources are then ignored. Only for commits where no type + * changes privacy or schema and none is removed. + */ + prebuilt?: { + trees: Record + refs: FileRefDelta + stats: { added: number; removed: number; updated: number } + } + /** Validate a record's data against its type's schema; errors or null. Used when a schema changes. */ + validate?: (schema: Record, data: unknown) => string[] | null + /** + * Migration only: keep a v1 version's semver (v1's rules may have differed + * over time) and creation time, and skip the post-publish job (webhooks + * would fire for history). + */ + migrated?: { semver: string; createdAt: Date } +} + +export type CommitResult = + | { status: 'committed'; version: typeof schema.versions.$inferSelect; written: string[] } + | { status: 'no_changes'; versionHash: string } + | { status: 'conflict'; headVersionId: string | null } + | { + status: 'invalid' + errors: { recordId: string; type: string; errors: string[] }[] + total: number + } + +export async function commitVersion(ports: Ports, input: CommitInput): Promise { + const { db } = ports + const repo = await ports.stores.forCollection(input.collectionId) + const [collection] = await db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, input.collectionId)) + .limit(1) + if (!collection) throw new Error(`Collection ${input.collectionId} not found`) + const fence = input.fence ?? (await writeFence(db)) + + const built = await buildVersion(repo, { + base: input.base, + types: input.types, + metadata: input.metadata, + ...(input.declaredFiles ? { declaredFiles: input.declaredFiles } : {}), + salt: collection.privateSalt, + // Migration copies v1's files as they were; every other commit proves possession. + fileSizes: (hashes) => + input.migrated + ? fileSizes(db, hashes) + : collectionFileSizes(db, repo, collection, input.base?.hash ?? null, hashes), + ...(input.validate ? { validate: input.validate } : {}), + ...(input.prebuilt ? { prebuilt: input.prebuilt } : {}), + }) + if (built.status === 'invalid') return built + const base = input.base + const { + versionHash, + root, + basePublic, + basePrivate, + newPublic, + newPrivate, + stats, + schemaChanged, + recordsChanged, + } = built + if (base && versionHash === base.hash) return { status: 'no_changes', versionHash } + + const sv = input.migrated + ? parseSemver(input.migrated.semver) + : deriveSemver(base?.semver ?? null, schemaChanged, recordsChanged) + + const pubTotals = setRecordTotals(newPublic) + const privTotals = setRecordTotals(newPrivate) + const typeCounts: Record = {} + const publicTypeCounts: Record = {} + for (const [slug, t] of Object.entries(newPublic.types)) { + typeCounts[slug] = (typeCounts[slug] ?? 0) + t.count + publicTypeCounts[slug] = t.count + } + for (const [slug, t] of Object.entries(newPrivate.types)) + typeCounts[slug] = (typeCounts[slug] ?? 0) + t.count + + const usage: SchemaUsageChange[] = [] + for (const [set, before, after] of [ + ['public', basePublic, newPublic], + ['private', basePrivate, newPrivate], + ] as const) { + const slugs = new Set([...Object.keys(before.types), ...Object.keys(after.types)]) + for (const slug of slugs) { + const was = before.types[slug]?.schema ?? null + const now = after.types[slug]?.schema ?? null + if (was !== now) usage.push({ set, typeSlug: slug, schemaHash: now, wasOpen: was !== null }) + } + } + + // The cumulative public files tree: add files that entered the public set. + const publicFilesRoot = await mergeCumulativeFiles( + ports, + repo, + collection.publicFilesRoot, + built.publicFilesAdded, + ) + + const versionId = crypto.randomUUID() + const versionRow = { + id: versionId, + collectionId: input.collectionId, + seq: (base?.seq ?? 0) + 1, + semver: sv.semver, + major: sv.major, + minor: sv.minor, + patch: sv.patch, + hash: versionHash, + baseSemver: base?.semver ?? null, + message: input.message ?? null, + pushedBy: input.pushedBy ?? null, + appId: input.appId ?? null, + actorId: input.actorId ?? null, + recordCount: pubTotals.count + privTotals.count, + publicRecordCount: pubTotals.count, + fileCount: newPublic.files.count + newPrivate.files.count, + totalBytes: pubTotals.bytes + privTotals.bytes + newPublic.files.bytes + newPrivate.files.bytes, + publicFileCount: newPublic.files.count, + publicTotalBytes: pubTotals.bytes + newPublic.files.bytes, + typeCounts, + publicTypeCounts, + hasPrivate: root.private !== null, + publicRefsRoot: built.publicRefsRoot, + privateRefsRoot: built.privateRefsRoot, + changes: stats, + pushSessionId: input.pushSessionId ?? null, + ...(input.migrated ? { createdAt: input.migrated.createdAt } : {}), + } + const published = await publishVersion(db, { + fence, + version: versionRow, + baseVersionId: base?.id ?? null, + collectionUpdate: { + publicFilesRoot, + summary: base ? summarize(input.metadata) : firstSummary(input.metadata, collection.summary), + }, + schemaHashes: [...new Set(input.types.map((t) => t.schemaHash))], + usage, + }) + if (published.fenced) throw new FenceError() + if (!published.ok) return { status: 'conflict', headVersionId: published.headVersionId } + + const [version] = await db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, versionId)) + .limit(1) + + // The version log and head, after every object the version reaches. If this + // fails the version is still published; the repair job rewrites the log. + try { + await appendVersionLog(ports, repo, input.collectionId, version!) + } catch (err) { + console.error(`[commit] version log for ${versionId} failed; queued a repair`, err) + await ports.jobs.enqueue({ type: 'repo.repairLog', collectionId: input.collectionId }) + } + // Migrated history gets its reference-log events but fires no webhooks. + await ports.jobs.enqueue( + input.migrated + ? { type: 'refs.index', versionId } + : { type: 'version.published', versionId, bump: bumpType(schemaChanged, recordsChanged) }, + ) + return { status: 'committed', version: version!, written: built.written } +} + +/** Bounded fields for collection lists, from the version metadata. */ +export function summarize( + metadata: Record | null, +): schema.CollectionSummary | null { + if (!metadata) return null + const str = (v: unknown, max: number) => (typeof v === 'string' ? v.slice(0, max) : undefined) + const out: schema.CollectionSummary = {} + const title = str(metadata.title ?? metadata.name, 200) + const description = str(metadata.description, 1000) + const license = str(metadata.license, 100) + if (title) out.title = title + if (description) out.description = description + if (license) out.license = license + if (Array.isArray(metadata.tags)) { + out.tags = metadata.tags + .filter((t): t is string => typeof t === 'string') + .slice(0, 20) + .map((t) => t.slice(0, 50)) + } + return out +} + +/** + * The first version's summary. A description given when the collection was + * made stays unless the first version's metadata has one. + */ +export function firstSummary( + metadata: Record | null, + made: schema.CollectionSummary | null, +): schema.CollectionSummary | null { + const out = summarize(metadata) + if (!made?.description || out?.description) return out + return { ...out, description: made.description } +} + +export async function mergeCumulativeFiles( + ports: Ports, + repo: Repo, + root: string | null, + added: string[], +): Promise { + if (added.length === 0) return root + const sizes = await fileSizes(ports.db, added) + const changes = added + .slice() + .sort(compareUtf8) + .map((h) => ({ key: h, entry: { key: h, size: sizes.get(h)! } })) + const sink = new RepoSink(repo) + const merged = await mergeTree(new RepoSource(fileTree, repo), sink, root, changes) + await sink.flush() + return merged.root?.hash ?? null +} + +/** Write the signed log entry for a published version, then head.json. Idempotent. */ +export async function appendVersionLog( + ports: Ports, + repo: Repo, + collectionId: string, + v: Pick< + typeof schema.versions.$inferSelect, + 'seq' | 'semver' | 'hash' | 'baseSemver' | 'message' | 'appId' | 'createdAt' + >, +): Promise { + const head = await readHead(repo, collectionId) + if (head && head.seq >= v.seq) return + if ((head?.seq ?? 0) !== v.seq - 1) { + throw new Error(`Log for ${collectionId} is at ${head?.seq ?? 0}, cannot append ${v.seq}`) + } + const signer = await ports.signer() + // collection.json first: a reader must find the key before an entry it signs. + await publishCollectionInfo(ports, repo, collectionId, signer) + const entry = await signEntry(signer, { + collectionId, + seq: v.seq, + semver: v.semver, + versionHash: v.hash, + baseSemver: v.baseSemver, + message: v.message, + appId: v.appId, + // The pusher's actor id is for the collection's members (it stays on the + // version row); the log is public with its collection, and on mirrors. + actorId: null, + createdAt: v.createdAt.toISOString(), + prev: head?.entryHash ?? null, + }) + await appendLog(repo, collectionId, entry) +} diff --git a/packages/server/src/versions/file-refs.ts b/packages/server/src/versions/file-refs.ts new file mode 100644 index 0000000..61347a9 --- /dev/null +++ b/packages/server/src/versions/file-refs.ts @@ -0,0 +1,94 @@ +/** + * File sets on the platform: the protocol package's file-set bookkeeping + * (src/repo/file-sets.ts), with file sizes from the files table. + */ +import { + applyFileSet as applyFileSetWith, + type FileSetResult, + fileTree, + getEntry, + type Repo, + RepoSource, + type TreeSummary, +} from '@underlay/protocol' +import { and, eq } from 'drizzle-orm' + +import { chunks, inJson, JSON_CHUNK } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import type { Db } from '../ports.js' + +/** File sizes for hashes, from the files table: a query per JSON_CHUNK hashes. */ +export async function fileSizes(db: Db, hashes: string[]): Promise> { + const out = new Map() + for (const part of chunks(hashes, JSON_CHUNK)) { + const rows = await db + .select({ hash: schema.files.hash, size: schema.files.size }) + .from(schema.files) + .where(inJson(schema.files.hash, part)) + for (const r of rows) out.set(r.hash, r.size) + } + return out +} + +/** + * Sizes of the files a commit to this collection may reference. A file counts + * only if the collection already has it (the base version's file trees, or its + * cumulative public files tree) or its bytes were uploaded and verified under + * this collection. Anything else is missing, whether or not another collection + * holds it, so a commit can't learn of or borrow another collection's file by + * hash (edge-redesign.md, Security notes). + */ +export async function collectionFileSizes( + db: Db, + repo: Repo, + collection: { id: string; publicFilesRoot: string | null }, + baseHash: string | null, + hashes: string[], +): Promise> { + const out = new Map() + if (hashes.length === 0) return out + const roots = [collection.publicFilesRoot] + if (baseHash) { + const root = await repo.root(baseHash) + roots.push(root.public.files.root) + if (root.private) roots.push((await repo.privateSet(root.private)).files.root) + } + const source = new RepoSource(fileTree, repo) + const rest: string[] = [] + for (const h of hashes) { + let size: number | undefined + for (const r of roots) { + if (!r) continue + size = (await getEntry(source, r, h))?.size + if (size !== undefined) break + } + if (size === undefined) rest.push(h) + else out.set(h, size) + } + for (const part of chunks(rest, JSON_CHUNK)) { + const rows = await db + .select({ hash: schema.files.hash, size: schema.files.size }) + .from(schema.files) + .innerJoin(schema.fileUploads, eq(schema.fileUploads.hash, schema.files.hash)) + .where( + and( + inJson(schema.files.hash, part), + eq(schema.fileUploads.collectionId, collection.id), + eq(schema.fileUploads.status, 'verified'), + ), + ) + for (const r of rows) out.set(r.hash, r.size) + } + return out +} + +/** Apply a set's reference deltas to its count and file trees, with sizes from SQLite. */ +export function applyFileSet( + db: Db, + repo: Repo, + base: { refsRoot: string | null; files: TreeSummary }, + refDeltas: Map, + declared: { add: string[]; remove: string[] } | null, +): Promise { + return applyFileSetWith(repo, base, refDeltas, declared, (h) => fileSizes(db, h)) +} diff --git a/packages/server/src/versions/fork.ts b/packages/server/src/versions/fork.ts new file mode 100644 index 0000000..e7a9e93 --- /dev/null +++ b/packages/server/src/versions/fork.ts @@ -0,0 +1,158 @@ +/** + * Fork (edge-redesign.md, Commit): the fork's first version is a new root that + * reuses the source version's sets, plus a `forks` row written in the same + * publish batch. No data is copied. + * + * A fork by an owner keeps the private set; it inherits the source collection's + * salt so the private commitment (and so the version hash) stays the same and + * later pushes of the same content still dedupe. A fork by anyone else takes + * the public set only. Both collections must share a primary location, since a + * repository never references another location. + */ +import { makeRoot, newSalt } from '@underlay/protocol' +import { eq } from 'drizzle-orm' + +import { FenceError, fenced } from '../cleanup/fence.js' +import * as schema from '../db/schema.js' +import type { Ports } from '../ports.js' +import { appendVersionLog } from './commit.js' +import { publishVersion, type SchemaUsageChange } from './publish.js' + +export async function createCollectionRows( + ports: Ports, + c: { + organizationId: string + slug: string + name: string + public: boolean + privateSalt?: string + /** Until the first version's metadata replaces it. */ + summary?: schema.CollectionSummary + }, +): Promise { + const id = crypto.randomUUID() + await ports.db.batch([ + ports.db.insert(schema.collections).values({ + id, + organizationId: c.organizationId, + slug: c.slug, + name: c.name, + public: c.public, + privateSalt: c.privateSalt ?? newSalt(), + summary: c.summary ?? null, + }), + ports.db.insert(schema.placements).values({ + collectionId: id, + locationId: schema.PLATFORM_LOCATION_ID, + role: 'primary', + sets: 'public+private', + }), + ]) + const [row] = await ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, id)) + return row! +} + +export async function forkCollection( + ports: Ports, + source: { + collection: typeof schema.collections.$inferSelect + version: typeof schema.versions.$inferSelect + }, + target: { organizationId: string; slug: string; name: string; public: boolean }, + includePrivate: boolean, +): Promise<{ + collection: typeof schema.collections.$inferSelect + version: typeof schema.versions.$inferSelect +}> { + const repo = await ports.stores.forCollection(source.collection.id) + const root = await repo.root(source.version.hash) + const priv = includePrivate && root.private ? await repo.privateSet(root.private) : null + + const collection = await createCollectionRows(ports, { + ...target, + ...(priv ? { privateSalt: source.collection.privateSalt } : {}), + }) + const targetRepo = await ports.stores.forCollection(collection.id) + if (targetRepo !== repo) throw new Error('Forks across storage locations are not supported yet') + + const newRoot = makeRoot(root.metadata, root.public, priv) + const v = source.version + const keepPrivate = !!priv + const usage: SchemaUsageChange[] = [ + ...Object.entries(root.public.types).map(([slug, t]) => ({ + set: 'public' as const, + typeSlug: slug, + schemaHash: t.schema, + wasOpen: false, + })), + ...Object.entries(priv?.types ?? {}).map(([slug, t]) => ({ + set: 'private' as const, + typeSlug: slug, + schemaHash: t.schema, + wasOpen: false, + })), + ] + const versionId = crypto.randomUUID() + // The root write and the publish are one write phase under the storage fence. + await fenced(ports.db, async (fence) => { + const hash = await repo.putRoot(newRoot) + const published = await publishVersion(ports.db, { + fence, + version: { + id: versionId, + collectionId: collection.id, + seq: 1, + semver: 'v1.0.0', + major: 1, + minor: 0, + patch: 0, + hash, + baseSemver: null, + message: `Forked from ${source.collection.slug} ${v.semver}`, + pushedBy: null, + appId: null, + actorId: null, + recordCount: keepPrivate ? v.recordCount : v.publicRecordCount, + publicRecordCount: v.publicRecordCount, + fileCount: keepPrivate ? v.fileCount : v.publicFileCount, + totalBytes: keepPrivate ? v.totalBytes : v.publicTotalBytes, + publicFileCount: v.publicFileCount, + publicTotalBytes: v.publicTotalBytes, + typeCounts: keepPrivate ? v.typeCounts : v.publicTypeCounts, + publicTypeCounts: v.publicTypeCounts, + hasPrivate: newRoot.private !== null, + publicRefsRoot: v.publicRefsRoot, + privateRefsRoot: keepPrivate ? v.privateRefsRoot : null, + changes: { + added: keepPrivate ? v.recordCount : v.publicRecordCount, + removed: 0, + updated: 0, + }, + }, + baseVersionId: null, + collectionUpdate: { + publicFilesRoot: root.public.files.root, + summary: source.collection.summary, + }, + schemaHashes: [], + usage, + fork: { + parentCollectionId: source.collection.id, + parentSeq: v.seq, + sets: keepPrivate ? 'public+private' : 'public', + }, + }) + if (published.fenced) throw new FenceError() + if (!published.ok) throw new Error('Fork publish lost a race on a brand-new collection') + }) + const [version] = await ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, versionId)) + await appendVersionLog(ports, repo, collection.id, version!) + await ports.jobs.enqueue({ type: 'version.published', versionId, bump: 'major' }) + return { collection, version: version! } +} diff --git a/packages/server/src/versions/publish.ts b/packages/server/src/versions/publish.ts new file mode 100644 index 0000000..368f784 --- /dev/null +++ b/packages/server/src/versions/publish.ts @@ -0,0 +1,237 @@ +/** + * Publish: make a built version visible by moving the collection's head, with + * compare-and-swap (edge-redesign.md, Commit step 5). + * + * D1 has no interactive transactions, so the CAS is one atomic batch whose + * statements all carry the condition in SQL: + * 1. insert the version row only if the head is still the base; + * 2. move the head only if it is still the base and the row from 1 exists; + * 3. close and open schema_usage rows only if the row from 1 exists; + * 4. for a fork's first version, the forks row, only if the row from 1 exists. + * If another commit won, 1 inserts nothing and the rest match nothing; the + * caller sees that by reading the head back. Everything the version points to is + * already in the repository, so there is never a half-built version. + * + * 1 also holds only while the storage fence the caller's write phase began + * under is unchanged (cleanup/fence.ts): if a deletion window opened since, an + * object the version reuses may be gone, so the caller redoes its writes. + */ +import { type SetName } from '@underlay/protocol' +import { and, eq, isNull, sql } from 'drizzle-orm' + +import { fenceHolds, fenceMoved } from '../cleanup/fence.js' +import * as schema from '../db/schema.js' +import type { Db } from '../ports.js' + +export interface NewVersionRow { + id: string + collectionId: string + seq: number + semver: string + major: number + minor: number + patch: number + hash: string + baseSemver: string | null + message: string | null + pushedBy: string | null + appId: string | null + actorId: string | null + recordCount: number + publicRecordCount: number + fileCount: number + totalBytes: number + publicFileCount: number + publicTotalBytes: number + typeCounts: Record + publicTypeCounts: Record + hasPrivate: boolean + publicRefsRoot: string | null + privateRefsRoot: string | null + changes: { added: number; removed: number; updated: number } + /** The push session committing it, if any. */ + pushSessionId?: string | null + /** Migration only: the v1 version's time. */ + createdAt?: Date +} + +export interface SchemaUsageChange { + set: SetName + typeSlug: string + /** The schema the type now uses in the set, or null when it left the set. */ + schemaHash: string | null + /** Whether a usage row is currently open (the type was in the set at the base). */ + wasOpen: boolean +} + +export interface PublishInput { + version: NewVersionRow + /** The storage fence epoch read before the version's objects were written. */ + fence: number + baseVersionId: string | null + collectionUpdate: { + publicFilesRoot: string | null + summary: schema.CollectionSummary | null + } + schemaHashes: string[] + usage: SchemaUsageChange[] + /** A fork's first version: where it came from and which sets it carried. */ + fork?: { + parentCollectionId: string + parentSeq: number + sets: 'public' | 'public+private' + } +} + +export async function publishVersion( + db: Db, + p: PublishInput, +): Promise<{ ok: boolean; headVersionId: string | null; fenced: boolean }> { + const v = p.version + const now = Date.now() + const createdAt = v.createdAt?.getTime() ?? now + // Literal values for INSERT … SELECT. Raw SQL bypasses column mapping, so JSON + // and booleans are given in their stored form. + const lit = (value: unknown) => sql`${value}` + const versionExists = sql`EXISTS (SELECT 1 FROM ${schema.versions} WHERE ${schema.versions.id} = ${v.id})` + const headIsBase = and( + eq(schema.collections.id, v.collectionId), + sql`${schema.collections.headVersionId} IS ${p.baseVersionId}`, + ) + + // Only query builders can go in a D1 batch (raw db.run() can't), so the + // conditions are INSERT … SELECT from the row they depend on, and UPDATE … WHERE. + const statements = [ + // 1. The version row, only if the head is still the base: selected from the + // collection row, filtered by that condition. + db.insert(schema.versions).select( + db + .select({ + id: lit(v.id).as('id'), + collectionId: lit(v.collectionId).as('collection_id'), + seq: lit(v.seq).as('seq'), + semver: lit(v.semver).as('semver'), + major: lit(v.major).as('major'), + minor: lit(v.minor).as('minor'), + patch: lit(v.patch).as('patch'), + hash: lit(v.hash).as('hash'), + baseSemver: lit(v.baseSemver).as('base_semver'), + message: lit(v.message).as('message'), + pushedBy: lit(v.pushedBy).as('pushed_by'), + appId: lit(v.appId).as('app_id'), + actorId: lit(v.actorId).as('actor_id'), + signature: lit(null).as('signature'), + recordCount: lit(v.recordCount).as('record_count'), + publicRecordCount: lit(v.publicRecordCount).as('public_record_count'), + fileCount: lit(v.fileCount).as('file_count'), + totalBytes: lit(v.totalBytes).as('total_bytes'), + publicFileCount: lit(v.publicFileCount).as('public_file_count'), + publicTotalBytes: lit(v.publicTotalBytes).as('public_total_bytes'), + typeCounts: lit(JSON.stringify(v.typeCounts)).as('type_counts'), + publicTypeCounts: lit(JSON.stringify(v.publicTypeCounts)).as( + 'public_type_counts', + ), + hasPrivate: lit(v.hasPrivate ? 1 : 0).as('has_private'), + publicRefsRoot: lit(v.publicRefsRoot).as('public_refs_root'), + privateRefsRoot: lit(v.privateRefsRoot).as('private_refs_root'), + refsIndexed: lit(0).as('refs_indexed'), + refEvents: lit(null).as('ref_events'), + refBytes: lit(null).as('ref_bytes'), + reconciledAt: lit(null).as('reconciled_at'), + reconcileReport: lit(null).as('reconcile_report'), + changes: lit(JSON.stringify(v.changes)).as('changes'), + createdAt: lit(createdAt).as('created_at'), + publishedAt: lit(now).as('published_at'), + pushSessionId: lit(v.pushSessionId ?? null).as('push_session_id'), + }) + .from(schema.collections) + .where(and(headIsBase, fenceHolds(p.fence))) as never, + ), + // 2. Move the head, only if it is still the base and the row from 1 exists. + db + .update(schema.collections) + .set({ + headVersionId: v.id, + updatedAt: new Date(now), + publicFilesRoot: p.collectionUpdate.publicFilesRoot, + summary: p.collectionUpdate.summary, + versionCount: sql`${schema.collections.versionCount} + 1`, + historyBytes: sql`${schema.collections.historyBytes} + ${v.totalBytes}`, + lastPushAt: sql`max(coalesce(${schema.collections.lastPushAt}, 0), ${createdAt})`, + }) + .where(and(headIsBase, versionExists)), + ...p.schemaHashes.map((h) => + db.insert(schema.schemas).values({ hash: h }).onConflictDoNothing(), + ), + // 3. Schema usage, only if the row from 1 exists. + ...p.usage.flatMap((u) => { + const out = [] + if (u.wasOpen) { + out.push( + db + .update(schema.schemaUsage) + .set({ toSeq: v.seq }) + .where( + and( + eq(schema.schemaUsage.collectionId, v.collectionId), + eq(schema.schemaUsage.typeSlug, u.typeSlug), + eq(schema.schemaUsage.set, u.set), + isNull(schema.schemaUsage.toSeq), + versionExists, + ), + ), + ) + } + if (u.schemaHash !== null) { + out.push( + db.insert(schema.schemaUsage).select( + db + .select({ + schemaHash: lit(u.schemaHash).as('schema_hash'), + collectionId: lit(v.collectionId).as('collection_id'), + typeSlug: lit(u.typeSlug).as('type_slug'), + set: lit(u.set).as('set'), + fromSeq: lit(v.seq).as('from_seq'), + toSeq: lit(null).as('to_seq'), + }) + .from(schema.versions) + .where(eq(schema.versions.id, v.id)) as never, + ), + ) + } + return out + }), + // 4. The fork row, only if the row from 1 exists. + ...(p.fork + ? [ + db.insert(schema.forks).select( + db + .select({ + childCollectionId: lit(v.collectionId).as('child_collection_id'), + parentCollectionId: lit(p.fork.parentCollectionId).as( + 'parent_collection_id', + ), + parentSeq: lit(p.fork.parentSeq).as('parent_seq'), + sets: lit(p.fork.sets).as('sets'), + createdAt: lit(now).as('created_at'), + }) + .from(schema.versions) + .where(eq(schema.versions.id, v.id)) as never, + ), + ] + : []), + ] + await db.batch(statements as unknown as Parameters[0]) + + const [row] = await db + .select({ head: schema.collections.headVersionId }) + .from(schema.collections) + .where(eq(schema.collections.id, v.collectionId)) + .limit(1) + const ok = row?.head === v.id + return { + ok, + headVersionId: row?.head ?? null, + fenced: !ok && (await fenceMoved(db, p.fence)), + } +} diff --git a/packages/server/src/versions/view.ts b/packages/server/src/versions/view.ts new file mode 100644 index 0000000..3ead44f --- /dev/null +++ b/packages/server/src/versions/view.ts @@ -0,0 +1,236 @@ +/** + * What a caller can see of a version: the read path's single place for privacy. + * + * Authorization picks the sets (public, or public + private) once; everything + * after that is set-scoped reading of trees. Nothing filters individual records. + */ +import { + compareUtf8, + entryAt, + getEntry, + iterate, + parseSemver, + type PrivateSetObject, + rankOf, + type RecordEntry, + recordTree, + type Repo, + RepoSource, + type SetObject, + type TreeSummary, + type VersionRoot, +} from '@underlay/protocol' +import { and, desc, eq } from 'drizzle-orm' + +import * as schema from '../db/schema.js' +import type { Db } from '../ports.js' + +export type VersionRow = typeof schema.versions.$inferSelect + +/** Find a version by semver ("v1.2.3", "1.2"), "latest", or v2 hash. */ +export async function findVersion( + db: Db, + collectionId: string, + n: string, + headVersionId: string | null, +): Promise { + let where + if (n === 'latest') { + if (!headVersionId) return null + where = eq(schema.versions.id, headVersionId) + } else if (n.startsWith('ulv2:')) { + where = and(eq(schema.versions.collectionId, collectionId), eq(schema.versions.hash, n)) + } else { + where = and( + eq(schema.versions.collectionId, collectionId), + eq(schema.versions.semver, parseSemver(n).semver), + ) + } + const [v] = await db + .select() + .from(schema.versions) + .where(where) + .orderBy(desc(schema.versions.seq)) + .limit(1) + return v ?? null +} + +export interface TypeView { + slug: string + schemaHash: string + /** Trees this caller may read, per set. */ + public: TreeSummary | null + private: TreeSummary | null + /** Records visible to this caller. */ + count: number + bytes: number + /** True when the whole type is private. */ + privateType: boolean +} + +export interface VersionView { + version: VersionRow + repo: Repo + root: VersionRoot + public: SetObject + /** Only when the caller may read the private set. */ + private: PrivateSetObject | null + types: TypeView[] + owner: boolean + /** Record hashes never served (the denylist); typeRecords and getRecord skip them. */ + withheld: ReadonlySet +} + +export async function loadView( + repo: Repo, + version: VersionRow, + owner: boolean, + withheld: ReadonlySet = new Set(), +): Promise { + const root = await repo.root(version.hash) + const priv = owner && root.private ? await repo.privateSet(root.private) : null + const slugs = new Set([...Object.keys(root.public.types), ...Object.keys(priv?.types ?? {})]) + const types = [...slugs].sort(compareUtf8).map((slug): TypeView => { + const pub = root.public.types[slug] ?? null + const pri = priv?.types[slug] ?? null + return { + slug, + schemaHash: (pub ?? pri)!.schema, + public: pub, + private: pri, + count: (pub?.count ?? 0) + (pri?.count ?? 0), + bytes: (pub?.bytes ?? 0) + (pri?.bytes ?? 0), + privateType: !pub && !!pri, + } + }) + return { version, repo, root, public: root.public, private: priv, types, owner, withheld } +} + +export type VisibleRecord = RecordEntry & { type: string; set: 'public' | 'private' } + +/** Merge two sorted streams by key (keys are unique across sets). */ +async function* mergeById( + a: AsyncIterable, + b: AsyncIterable, +): AsyncGenerator<{ e: RecordEntry; set: 'public' | 'private' }> { + const ai = a[Symbol.asyncIterator]() + const bi = b[Symbol.asyncIterator]() + let x = await ai.next() + let y = await bi.next() + while (!x.done || !y.done) { + if (y.done || (!x.done && compareUtf8(x.value.key, y.value.key) < 0)) { + yield { e: x.value, set: 'public' } + x = await ai.next() + } else { + yield { e: y.value, set: 'private' } + y = await bi.next() + } + } +} + +/** + * Records of one type, in id order, from a position. `offset` seeks in + * O(height × log n) even across two sets, by binary search on ranks. + */ +export async function* typeRecords( + view: VersionView, + type: TypeView, + opts: { after?: string; offset?: number; bodies?: boolean } = {}, +): AsyncGenerator { + const source = new RepoSource(recordTree, view.repo) + const pubRoot = type.public?.root ?? null + const privRoot = view.owner ? (type.private?.root ?? null) : null + let after = opts.after + if (opts.offset && opts.offset > 0) { + if (opts.offset >= type.count) return + after = await keyBeforeOffset(source, pubRoot, privRoot, opts.offset) + } + const it = (root: string | null) => + iterate(source, root, { + payloads: opts.bodies ?? false, + ...(after !== undefined ? { after } : {}), + }) + for await (const { e, set } of mergeById(it(pubRoot), it(privRoot))) { + if (view.withheld.has(e.hash)) continue + yield { ...e, type: type.slug, set } + } +} + +/** + * How many of a type's records this caller may read come after `after`, from + * the trees' counts: O(height) per set. Withheld records are counted, as in + * `count`. + */ +export async function countAfter(view: VersionView, type: TypeView, after: string) { + const source = new RepoSource(recordTree, view.repo) + // Keys ≤ after are the keys < after + "\0": nothing sorts between the two. + const upTo = after + '\0' + let n = 0 + for (const tree of [type.public, view.owner ? type.private : null]) { + if (tree?.root) n += tree.count - (await rankOf(source, tree.root, upTo)) + } + return n +} + +/** The key just before global position `offset` across two trees. */ +async function keyBeforeOffset( + source: RepoSource, + a: string | null, + b: string | null, + offset: number, +): Promise { + if (b === null) return (await entryAt(source, a, offset - 1))!.key + if (a === null) return (await entryAt(source, b, offset - 1))!.key + // The (offset-1)th key overall is the larger of the last keys taken from each + // tree; find how many come from `a` by binary search on ranks in `b`. + let lo = Math.max(0, offset - (await countOf(source, b))) + let hi = Math.min(offset, await countOf(source, a)) + while (lo < hi) { + const i = (lo + hi) >> 1 // take i from a, offset - i from b + const ka = (await entryAt(source, a, i))!.key + if ((await rankOf(source, b, ka)) + i < offset) lo = i + 1 + else hi = i + } + const fromA = lo + const lastA = fromA > 0 ? (await entryAt(source, a, fromA - 1))!.key : null + const lastB = offset - fromA > 0 ? (await entryAt(source, b, offset - fromA - 1))!.key : null + if (lastA === null) return lastB! + if (lastB === null) return lastA + return compareUtf8(lastA, lastB) > 0 ? lastA : lastB +} + +async function countOf(source: RepoSource, root: string): Promise { + const n = await source.node(root) + return n.kind === 'leaf' ? n.entries.length : n.children.reduce((s, c) => s + c.count, 0) +} + +/** One record by type and id, with its body. */ +export async function getRecord( + view: VersionView, + type: TypeView, + id: string, +): Promise { + const source = new RepoSource(recordTree, view.repo) + for (const [set, tree] of [ + ['public', type.public], + ['private', view.owner ? type.private : null], + ] as const) { + if (!tree?.root) continue + const hit = await getEntry(source, tree.root, id) + if (!hit) continue + if (view.withheld.has(hit.hash)) return null + const body = await bodyOf(source, tree.root, id) + return { ...hit, body, type: type.slug, set } + } + return null +} + +async function bodyOf(source: RepoSource, root: string, key: string): Promise { + let hash = root + for (;;) { + const node = await source.node(hash) + if (node.kind === 'leaf') + return (await source.leafEntries(hash)).find((e) => e.key === key)!.body! + hash = node.children.find((c) => compareUtf8(key, c.lastKey) <= 0)!.hash + } +} diff --git a/packages/server/src/web.d.ts b/packages/server/src/web.d.ts new file mode 100644 index 0000000..c12a902 --- /dev/null +++ b/packages/server/src/web.d.ts @@ -0,0 +1,4 @@ +// @underlay/web ships built JavaScript (dist/server/entry-server.js); this is its contract. +declare module '@underlay/web' { + export const renderPage: import('./app.js').RenderPage +} diff --git a/packages/server/src/webhooks/webhooks.ts b/packages/server/src/webhooks/webhooks.ts new file mode 100644 index 0000000..3f2fb37 --- /dev/null +++ b/packages/server/src/webhooks/webhooks.ts @@ -0,0 +1,283 @@ +/** + * Webhooks: v1's payload, headers and signature, delivered by jobs. + * + * version.published job → one delivery row per enabled, matching webhook → + * one webhooks.deliver job per row; failures retry as delayed jobs with + * exponential backoff (1 min doubling, capped at 6 h), up to 5 attempts. + * + * Receivers verify `x-underlay-signature: sha256=`. + * + * SSRF: URLs are checked at registration (scheme, blocked hostnames, private IP + * literals). On Workers there is no private network to reach. On Node, the + * deployment's outbound fetch resolves and refuses private addresses + * (Ports.outboundFetch). + */ +import { createHmac, randomBytes } from 'node:crypto' + +import type { BumpType } from '@underlay/protocol' +import { and, eq, lt } from 'drizzle-orm' + +import { chunks } from '../db/chunks.js' +import * as schema from '../db/schema.js' +import { registerJob } from '../jobs.js' +import type { Ports } from '../ports.js' + +const DELIVERY_TIMEOUT_MS = 10_000 +export const MAX_ATTEMPTS = 5 +const BACKOFF_BASE_S = 60 +const BACKOFF_CAP_S = 6 * 60 * 60 +const RETENTION_MS = 30 * 24 * 60 * 60 * 1000 +const SIGNATURE_HEADER = 'x-underlay-signature' + +export const generateWebhookSecret = () => `ulwhsec_${randomBytes(24).toString('hex')}` +export const signPayload = (secret: string, body: string) => + `sha256=${createHmac('sha256', secret).update(body).digest('hex')}` + +// --- SSRF checks (ported from v1) --------------------------------------------------- + +const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/ + +function parseIPv6(ip: string): number[] | null { + let s = ip.toLowerCase() + const zone = s.indexOf('%') + if (zone !== -1) s = s.slice(0, zone) + const dotted = s.match(/(\d+)\.(\d+)\.(\d+)\.(\d+)$/) + if (dotted) { + const [a = 0, b = 0, c = 0, d = 0] = dotted.slice(1).map((n) => parseInt(n, 10)) + s = `${s.slice(0, -dotted[0].length)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}` + } + const halves = s.split('::') + if (halves.length > 2) return null + const head = halves[0] ? halves[0].split(':') : [] + const tail = halves[1] ? halves[1].split(':') : [] + const fill = 8 - head.length - tail.length + if (halves.length === 1 ? fill !== 0 : fill < 0) return null + const groups = [...head, ...Array(fill).fill('0'), ...tail].map((g) => parseInt(g, 16)) + return groups.length === 8 && groups.every((g) => Number.isInteger(g) && g >= 0 && g <= 0xffff) + ? groups + : null +} + +export function ipKind(s: string): 0 | 4 | 6 { + const m = IPV4.exec(s) + if (m) return m.slice(1).every((n) => Number(n) <= 255) ? 4 : 0 + return s.includes(':') && parseIPv6(s) ? 6 : 0 +} + +/** Private, loopback, link-local, CGNAT, benchmarking, multicast, reserved, NAT64 and embedded IPv4. */ +export function isPrivateIp(ip: string): boolean { + const kind = ipKind(ip) + if (kind === 4) { + const [a = 0, b = 0] = ip.split('.').map((n) => parseInt(n, 10)) + if (a === 10 || a === 127 || a === 0) return true + if (a === 169 && b === 254) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 100 && b >= 64 && b <= 127) return true + if (a === 198 && (b === 18 || b === 19)) return true + return a >= 224 + } + if (kind === 6) { + const g = parseIPv6(ip) + if (!g) return true + const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0] = g + if (g.slice(0, 7).every((x) => x === 0) && g7 <= 1) return true + if ((g0 & 0xffc0) === 0xfe80) return true + if ((g0 & 0xfe00) === 0xfc00) return true + if ((g0 & 0xff00) === 0xff00) return true + if (g0 === 0x64 && g1 === 0xff9b && !g2 && !g3 && !g4 && !g5) return true + if (!g0 && !g1 && !g2 && !g3 && !g4 && (g5 === 0xffff || g5 === 0)) { + return isPrivateIp(`${g6 >> 8}.${g6 & 0xff}.${g7 >> 8}.${g7 & 0xff}`) + } + return false + } + return false +} + +const bareHost = (h: string) => (h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h) + +export function validateWebhookUrl( + raw: string, + allowInsecure: boolean, +): { ok: true; url: string } | { ok: false; reason: string } { + let parsed: URL + try { + parsed = new URL(raw) + } catch { + return { ok: false, reason: 'Invalid URL' } + } + if (parsed.protocol !== 'https:' && !(allowInsecure && parsed.protocol === 'http:')) { + return { ok: false, reason: 'Webhook URL must use https' } + } + const host = bareHost(parsed.hostname).toLowerCase().replace(/\.$/, '') + if ( + host === 'localhost' || + host.endsWith('.localhost') || + host.endsWith('.local') || + host.endsWith('.internal') + ) { + return { ok: false, reason: 'Webhook URL host is not allowed' } + } + if (ipKind(host) && isPrivateIp(host)) + return { ok: false, reason: 'Webhook URL resolves to a private address' } + return { ok: true, url: parsed.toString() } +} + +// --- Enqueue and deliver ---------------------------------------------------------------- + +/** After a version is published: a delivery row and a job per matching webhook. */ +export async function enqueueDeliveries( + ports: Ports, + versionId: string, + bump: BumpType, +): Promise { + const { db } = ports + const [row] = await db + .select({ v: schema.versions, c: schema.collections, o: schema.organization }) + .from(schema.versions) + .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId)) + .innerJoin(schema.organization, eq(schema.organization.id, schema.collections.organizationId)) + .where(eq(schema.versions.id, versionId)) + .limit(1) + if (!row) return 0 + const hooks = ( + await db + .select() + .from(schema.collectionWebhooks) + .where( + and( + eq(schema.collectionWebhooks.collectionId, row.c.id), + eq(schema.collectionWebhooks.enabled, true), + ), + ) + ).filter((h) => h.bumpFilter.includes(bump)) + if (hooks.length === 0) return 0 + // Idempotent per (webhook, version): a retried job doesn't double-deliver. + const existing = await db + .select({ webhookId: schema.webhookDeliveries.webhookId }) + .from(schema.webhookDeliveries) + .where(eq(schema.webhookDeliveries.versionId, versionId)) + const done = new Set(existing.map((e) => e.webhookId)) + const payload = { + event: 'version.created', + collection: { owner: row.o.slug, slug: row.c.slug }, + version: { + semver: row.v.semver, + hash: row.v.hash, + major: row.v.major, + minor: row.v.minor, + patch: row.v.patch, + recordCount: row.v.recordCount, + fileCount: row.v.fileCount, + }, + bumpType: bump, + } + const fresh = hooks.filter((h) => !done.has(h.id)) + if (fresh.length === 0) return 0 + // A few rows per statement: each binds about 16 parameters, and D1 allows 100. + const rows: { id: string }[] = [] + for (const part of chunks(fresh, 5)) { + rows.push( + ...(await db + .insert(schema.webhookDeliveries) + .values( + part.map((h) => ({ + webhookId: h.id, + collectionId: row.c.id, + versionId, + semver: row.v.semver, + bumpType: bump, + payload, + })), + ) + .returning({ id: schema.webhookDeliveries.id })), + ) + } + await ports.jobs.enqueueBatch(rows.map((r) => ({ type: 'webhooks.deliver', deliveryId: r.id }))) + return rows.length +} + +export async function deliver(ports: Ports, deliveryId: string): Promise { + const { db } = ports + const [row] = await db + .select({ d: schema.webhookDeliveries, h: schema.collectionWebhooks }) + .from(schema.webhookDeliveries) + .innerJoin( + schema.collectionWebhooks, + eq(schema.collectionWebhooks.id, schema.webhookDeliveries.webhookId), + ) + .where(eq(schema.webhookDeliveries.id, deliveryId)) + .limit(1) + if (!row || row.d.status === 'success') return + const attempt = row.d.attempts + 1 + const body = JSON.stringify({ + ...row.d.payload, + delivery: { id: row.d.id, timestamp: new Date().toISOString() }, + }) + + const record = async ( + ok: boolean, + responseCode: number | null, + error: string | null, + durationMs: number, + terminal = false, + ) => { + const exhausted = !ok && (terminal || attempt >= MAX_ATTEMPTS) + const delay = Math.min(BACKOFF_BASE_S * 2 ** (attempt - 1), BACKOFF_CAP_S) + await db + .update(schema.webhookDeliveries) + .set({ + status: ok ? 'success' : 'failed', + attempts: attempt, + responseCode, + error: error?.slice(0, 2000) ?? null, + durationMs, + nextAttemptAt: ok || exhausted ? null : new Date(Date.now() + delay * 1000), + deliveredAt: new Date(), + }) + .where(eq(schema.webhookDeliveries.id, deliveryId)) + if (ok) { + await db + .update(schema.collectionWebhooks) + .set({ lastDeliveryAt: new Date() }) + .where(eq(schema.collectionWebhooks.id, row.h.id)) + } else if (!exhausted) { + await ports.jobs.enqueue({ type: 'webhooks.deliver', deliveryId }, { delaySeconds: delay }) + } + } + + if (!row.h.enabled) return record(false, null, 'Webhook disabled', 0, true) + const started = Date.now() + try { + const res = await ports.outboundFetch(row.h.url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'user-agent': 'Underlay-Webhook/2.0', + 'x-underlay-event': row.d.event, + 'x-underlay-delivery': row.d.id, + [SIGNATURE_HEADER]: signPayload(row.h.secret, body), + }, + body, + signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS), + redirect: 'manual', + }) + void res.body?.cancel().catch(() => {}) + await record(res.ok, res.status, res.ok ? null : `HTTP ${res.status}`, Date.now() - started) + } catch (err) { + await record( + false, + null, + err instanceof Error ? err.message : String(err), + Date.now() - started, + ) + } +} + +export async function purgeOldDeliveries(ports: Ports): Promise { + await ports.db + .delete(schema.webhookDeliveries) + .where(lt(schema.webhookDeliveries.createdAt, new Date(Date.now() - RETENTION_MS))) +} + +registerJob('webhooks.deliver', async (job, ports) => deliver(ports, String(job.deliveryId))) diff --git a/packages/server/src/worker.ts b/packages/server/src/worker.ts new file mode 100644 index 0000000..4583f56 --- /dev/null +++ b/packages/server/src/worker.ts @@ -0,0 +1,272 @@ +/** + * Cloudflare Workers entry: HTTP (fetch), background jobs (queue) and + * schedules (scheduled). Bindings and secrets are in wrangler.jsonc. + */ +import type { + D1Database, + ExecutionContext, + MessageBatch, + Queue, + ScheduledController, +} from '@cloudflare/workers-types' +import { ed25519Signer, type R2BucketLike, r2Store, s3Store, type Signer } from '@underlay/protocol' + +import './handlers.js' +import { renderPage } from '@underlay/web' + +import { createApp } from './app.js' +import { type Auth, authenticator, createAuth } from './auth/auth.js' +import { createKf, type Kf } from './auth/kf.js' +import { isolateUsageSink, newUsageBuffer, type UsageEvent, writeUsage } from './billing/usage.js' +import { CfCache } from './cache.js' +import { openD1 } from './db/d1.js' +import { readsAnyReplica } from './db/replicas.js' +import { isBulk, QueueJobs, runJob } from './jobs.js' +import { bindingRateLimiter, type RateLimitBinding } from './lib/limits.js' +import type { JobMessage, Ports, PublicAssets } from './ports.js' +import { createStores } from './stores.js' + +export interface Env { + DB: D1Database + JOBS: Queue + /** Bulk jobs (jobs.ts BULK_JOBS); without it, everything goes to JOBS. */ + JOBS_BULK?: Queue + /** Usage events (billing/usage.ts), a request's to a message; without it, nothing is metered. */ + USAGE?: Queue + APP_URL: string + DEPLOYMENT: string + R2_ENDPOINT: string + R2_BUCKET: string + R2_ACCESS_KEY_ID: string + R2_SECRET_ACCESS_KEY: string + /** + * Optional R2 binding on the same bucket as R2_BUCKET. When set, repository + * and internal objects go through the binding; file bytes keep the S3 API, + * which can presign. + */ + BUCKET?: R2BucketLike + SIGNING_KEY: string + /** 32 bytes, base64url: encrypts customer storage credentials. */ + LOCATION_KEY?: string + SESSION_SECRET: string + OIDC_ISSUER_URL: string + OIDC_CLIENT_ID: string + OIDC_CLIENT_SECRET: string + /** The KF account site, linked from the user menu. */ + OIDC_ACCOUNT_URL?: string + /** Rate-limit bindings (wrangler.jsonc "ratelimits"); without them, no limit. */ + RL_ANON?: RateLimitBinding + RL_USER?: RateLimitBinding + /** Anonymous pages, ARK and the auth routes (lib/limits.ts). */ + RL_PAGE?: RateLimitBinding + /** KF Auth's internal API key: KF orgs for new orgs, and /api/kf/summary. Optional. */ + AUTH_INTERNAL_API_KEY?: string + REPO_PREFIX?: string + INTERNAL_PREFIX?: string + /** + * Public assets (org logos): a world-readable bucket served at ASSETS_BASE_URL. + * Reached through the PUBLIC_ASSETS binding when there is one, otherwise + * ASSETS_BUCKET through the S3 API at R2_ENDPOINT, with ASSETS_ACCESS_KEY_ID + * and ASSETS_SECRET_ACCESS_KEY, or the R2_* keys when their token covers that + * bucket too. Without ASSETS_BASE_URL and one of the two, avatar uploads answer 503. + */ + ASSETS_BASE_URL?: string + PUBLIC_ASSETS?: R2BucketLike + ASSETS_BUCKET?: string + ASSETS_ACCESS_KEY_ID?: string + ASSETS_SECRET_ACCESS_KEY?: string +} + +/** Interactive jobs one invocation runs at once. */ +const INTERACTIVE_LANES = 4 + +let signer: Promise | null = null +/** Usage events this isolate hasn't sent yet (billing/usage.ts isolateUsageSink). */ +const usageBuffer = newUsageBuffer() + +/** Which D1 a request reads: any replica for anonymous reads (db/replicas.ts), else the primary. */ +function d1For(env: Env, req: Request): D1Database { + if (!env.DB.withSession || !readsAnyReplica(req)) return env.DB + return env.DB.withSession('first-unconstrained') as unknown as D1Database +} + +/** The primary, for long-lived clients (better-auth, KF Auth) built once per isolate. */ +const primaries = new WeakMap() +const primaryDb = (env: Env) => { + let db = primaries.get(env.DB) + if (!db) primaries.set(env.DB, (db = openD1(env.DB))) + return db +} + +function publicAssets(env: Env): PublicAssets | undefined { + if (!env.ASSETS_BASE_URL) return undefined + const baseUrl = env.ASSETS_BASE_URL.replace(/\/+$/, '') + if (env.PUBLIC_ASSETS) return { store: r2Store(env.PUBLIC_ASSETS), baseUrl } + const accessKeyId = env.ASSETS_ACCESS_KEY_ID ?? env.R2_ACCESS_KEY_ID + const secretAccessKey = env.ASSETS_SECRET_ACCESS_KEY ?? env.R2_SECRET_ACCESS_KEY + if (!env.ASSETS_BUCKET || !accessKeyId || !secretAccessKey) return undefined + const store = s3Store({ + endpoint: env.R2_ENDPOINT, + bucket: env.ASSETS_BUCKET, + accessKeyId, + secretAccessKey, + region: 'auto', + }) + return { store, baseUrl } +} + +function makePorts(env: Env, ctx: ExecutionContext, req?: Request): Ports { + const db = req ? openD1(d1For(env, req)) : primaryDb(env) + const cache = new CfCache(caches as never, env.DEPLOYMENT, (p) => ctx.waitUntil(p)) + const assets = publicAssets(env) + const s3 = s3Store({ + endpoint: env.R2_ENDPOINT, + bucket: env.R2_BUCKET, + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_SECRET_ACCESS_KEY, + region: 'auto', + }) + return { + db, + stores: createStores(db, cache, { + bucket: env.BUCKET ? r2Store(env.BUCKET) : s3, + files: s3, + repoPrefix: env.REPO_PREFIX ?? 'repo', + internalPrefix: env.INTERNAL_PREFIX ?? 'internal', + }), + cache, + signer: () => (signer ??= ed25519Signer(env.SIGNING_KEY)), + jobs: new QueueJobs(env.JOBS as never, (env.JOBS_BULK ?? env.JOBS) as never), + waitUntil: (p) => ctx.waitUntil(p), + outboundFetch: (url, init) => fetch(url, init), + locationFetch: (req) => fetch(req), + ...(env.LOCATION_KEY ? { locationKey: env.LOCATION_KEY } : {}), + ...(env.USAGE + ? { + usage: isolateUsageSink( + usageBuffer, + (p) => ctx.waitUntil(p), + (events: UsageEvent[]) => + (env.USAGE as unknown as { send(b: unknown): Promise }).send({ events }), + (events: UsageEvent[]) => writeUsage(makePorts(env, ctx), events), + ), + } + : {}), + ...(env.RL_ANON && env.RL_USER + ? { + rateLimit: bindingRateLimiter({ + anon: env.RL_ANON, + user: env.RL_USER, + page: env.RL_PAGE, + }), + } + : {}), + ...(assets ? { publicAssets: assets } : {}), + } +} + +// One KF Auth client and one better-auth instance per isolate and database binding. +const kfs = new WeakMap() +function kfFor(env: Env): Kf { + let kf = kfs.get(env.DB) + if (!kf) { + kf = createKf(primaryDb(env), { + // No private network on Workers: server-to-server calls use the public URL. + internalUrl: env.OIDC_ISSUER_URL, + clientId: env.OIDC_CLIENT_ID, + clientSecret: env.OIDC_CLIENT_SECRET, + internalApiKey: env.AUTH_INTERNAL_API_KEY, + }) + kfs.set(env.DB, kf) + } + return kf +} + +const auths = new WeakMap() +function authFor(env: Env, ports: Ports): Auth { + let auth = auths.get(env.DB) + if (!auth) { + auth = createAuth( + primaryDb(env), + { + appUrl: env.APP_URL, + secret: env.SESSION_SECRET, + oidc: { + issuerUrl: env.OIDC_ISSUER_URL, + // No private network on Workers: server-to-server calls use the public URL. + internalUrl: env.OIDC_ISSUER_URL, + clientId: env.OIDC_CLIENT_ID, + clientSecret: env.OIDC_CLIENT_SECRET, + }, + }, + ports.waitUntil, + kfFor(env), + ) + auths.set(env.DB, auth) + } + return auth +} + +const app = createApp((c) => { + const env = c.env as unknown as Env + const ports = makePorts(env, c.executionCtx as unknown as ExecutionContext, c.req.raw) + return { + ports, + config: { + appUrl: env.APP_URL, + deployment: env.DEPLOYMENT, + kfAuthUrl: env.OIDC_ISSUER_URL, + kfAccountUrl: env.OIDC_ACCOUNT_URL, + }, + authenticate: authenticator(() => authFor(env, ports)), + kf: kfFor(env), + authHandler: (req) => authFor(env, ports).handler(req), + renderPage, + } +}) + +export default { + fetch(req: Request, env: Env, ctx: ExecutionContext): Response | Promise { + return app.fetch(req, env as never, ctx as never) + }, + + async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext): Promise { + const ports = makePorts(env, ctx) + // The usage queue: each message (an isolate's batch) becomes one log object, + // named by its contents, so a redelivered message rewrites the same object. + if (batch.queue.endsWith('-usage')) { + for (const m of batch.messages) { + await writeUsage(ports, (m.body as unknown as { events: UsageEvent[] }).events ?? []) + m.ack() + } + return + } + const run = async (msg: (typeof batch.messages)[number]) => { + try { + await runJob(msg.body, ports) + msg.ack() + } catch (err) { + console.error(`[jobs] ${msg.body.type} failed (attempt ${msg.attempts}):`, err) + msg.retry({ delaySeconds: Math.min(3600, 2 ** msg.attempts) }) + } + } + // The bulk queue delivers one job per invocation (wrangler.jsonc); an + // interactive batch's small jobs run a few at a time, sharing its CPU. + const queue = [...batch.messages] + const lanes = batch.messages.some((m) => isBulk(m.body.type)) ? 1 : INTERACTIVE_LANES + await Promise.all( + Array.from({ length: Math.min(lanes, queue.length) }, async () => { + for (let m = queue.shift(); m; m = queue.shift()) await run(m) + }), + ) + }, + + async scheduled( + _controller: ScheduledController, + env: Env, + ctx: ExecutionContext, + ): Promise { + const ports = makePorts(env, ctx) + await ports.jobs.enqueue({ type: 'maintenance.sweep' }) + }, +} diff --git a/packages/server/test/accounts.test.ts b/packages/server/test/accounts.test.ts new file mode 100644 index 0000000..3bc23a8 --- /dev/null +++ b/packages/server/test/accounts.test.ts @@ -0,0 +1,192 @@ +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { cleanup } from './harness.js' +import { setup } from './kf-app.js' + +afterAll(cleanup) + +describe('account routes', () => { + it('serves the signed-in user, and refuses keys that are not a person', async () => { + const { h, call, user } = await setup() + await user('u1') + await h.member('u1') + + expect((await call('/api/accounts/me')).status).toBe(401) + const me = await (await call('/api/accounts/me', { user: 'u1' })).json() + expect(me).toMatchObject({ id: 'u1', slug: 'u1', email: 'u1@example.org' }) + expect(me.orgs.map((o: { slug: string }) => o.slug).sort()).toEqual(['org', 'u1']) + + expect((await call('/api/accounts/me', { user: 'u1', key: 'read' })).status).toBe(200) + expect((await call('/api/accounts/me', { user: 'u1', key: 'scoped' })).status).toBe(403) + expect((await call('/api/accounts/me', { user: 'u1', key: 'org' })).status).toBe(403) + expect( + (await call('/api/accounts/me', { method: 'PATCH', user: 'u1', key: 'read', json: {} })) + .status, + ).toBe(403) + + // The steward role and display name come from KF Auth. + const ctx = await (await call('/api/context', { user: 'u1' })).json() + expect(ctx.currentUser).toMatchObject({ kfRole: 'admin', displayName: 'Ada KF' }) + }) + + it('updates the personal profile, checking the slug', async () => { + const { call, db, user } = await setup() + await user('u1') + await user('u2') + const patch = (json: unknown) => call('/api/accounts/me', { method: 'PATCH', user: 'u1', json }) + + expect((await patch({ slug: 'u2' })).status).toBe(409) + expect((await patch({ slug: 'Bad Slug' })).status).toBe(422) + const ok = await patch({ slug: 'ada', displayName: 'Ada', bio: 'hi', website: null }) + expect(await ok.json()).toEqual({ ok: true, slug: 'ada' }) + const [org] = await db + .select() + .from(schema.organization) + .where(eq(schema.organization.id, 'p-u1')) + expect(org).toMatchObject({ slug: 'ada', name: 'Ada', bio: 'hi', website: null }) + }) + + it('deletes an account only when confirmed and empty', async () => { + const { call, db, user } = await setup() + await user('u1') + const del = (confirmSlug: string) => + call('/api/accounts/me', { method: 'DELETE', user: 'u1', json: { confirmSlug } }) + + expect((await del('nope')).status).toBe(422) + // A write key can't delete the account; a session or an admin key can. + const byKey = (key: string) => + call('/api/accounts/me', { method: 'DELETE', user: 'u1', key, json: { confirmSlug: 'u1' } }) + expect((await byKey('write')).status).toBe(403) + await db.insert(schema.collections).values({ + organizationId: 'p-u1', + slug: 'keep', + name: 'keep', + privateSalt: 'aa', + }) + const refused = await del('u1') + expect(refused.status).toBe(409) + expect((await refused.json()).error).toContain('1 collection') + + await db.delete(schema.collections).where(eq(schema.collections.slug, 'keep')) + expect((await del('u1')).status).toBe(200) + expect(await db.select().from(schema.user).where(eq(schema.user.id, 'u1'))).toEqual([]) + expect( + await db.select().from(schema.organization).where(eq(schema.organization.id, 'p-u1')), + ).toEqual([]) + }) + + it('lists and revokes the user’s own sessions', async () => { + const { call, db, user } = await setup() + await user('u1') + await user('u2') + const later = new Date(Date.now() + 86_400_000) + await db.insert(schema.session).values([ + { id: 's-u1', token: 't1', userId: 'u1', expiresAt: later, userAgent: 'Firefox' }, + { id: 's-other', token: 't2', userId: 'u1', expiresAt: later }, + { id: 's-old', token: 't3', userId: 'u1', expiresAt: new Date(Date.now() - 1000) }, + { id: 's-u2', token: 't4', userId: 'u2', expiresAt: later }, + ]) + + expect((await call('/api/accounts/me/sessions', { user: 'u1', key: 'read' })).status).toBe(401) + const list = (await (await call('/api/accounts/me/sessions', { user: 'u1' })).json()) as { + id: string + current: boolean + }[] + expect(list.map((s) => [s.id, s.current]).sort()).toEqual([ + ['s-other', false], + ['s-u1', true], + ]) + + const revoke = (id: string) => + call(`/api/accounts/me/sessions/${id}`, { method: 'DELETE', user: 'u1' }) + expect((await revoke('s-u2')).status).toBe(404) + expect((await revoke('s-other')).status).toBe(200) + expect((await db.select().from(schema.session)).map((s) => s.id).sort()).toEqual([ + 's-old', + 's-u1', + 's-u2', + ]) + }) + + it('accepts an invitation only for the invited email, once', async () => { + const { h, call, db, user } = await setup() + await h.member('u1') + await user('u2') + await user('u3') + const later = new Date(Date.now() + 86_400_000) + await db.insert(schema.invitation).values([ + { + id: 'inv', + organizationId: 'org1', + email: 'U2@example.org', + role: 'admin', + inviterId: 'u1', + expiresAt: later, + }, + { + id: 'old', + organizationId: 'org1', + email: 'u3@example.org', + inviterId: 'u1', + expiresAt: new Date(Date.now() - 1000), + }, + ]) + const accept = (u: string, token: string) => + call('/api/accounts/invitations/accept', { method: 'POST', user: u, json: { token } }) + + expect((await accept('u3', 'inv')).status).toBe(404) + expect((await accept('u3', 'old')).status).toBe(404) + const ok = await accept('u2', 'inv') + expect(await ok.json()).toEqual({ ok: true, orgSlug: 'org' }) + const [m] = await db.select().from(schema.member).where(eq(schema.member.userId, 'u2')) + expect(m).toBeDefined() + const roles = await db + .select({ role: schema.member.role, org: schema.member.organizationId }) + .from(schema.member) + .where(eq(schema.member.userId, 'u2')) + expect(roles).toContainEqual({ role: 'admin', org: 'org1' }) + expect((await accept('u2', 'inv')).status).toBe(404) + }) + + it('updates and deletes orgs for owners only', async () => { + const { h, call, db, user } = await setup() + await h.member('u1') + await user('u2') + await db.insert(schema.member).values({ organizationId: 'org1', userId: 'u2', role: 'admin' }) + const patch = (u: string, json: unknown) => + call('/api/accounts/org', { method: 'PATCH', user: u, json }) + + expect((await patch('u2', { displayName: 'X' })).status).toBe(403) + // An owner's write key acts as a member; an admin key keeps the role. + const asKey = (key: string, method: string, json?: unknown) => + call('/api/accounts/org', { method, user: 'u1', key, ...(json ? { json } : {}) }) + expect((await asKey('write', 'PATCH', { displayName: 'X' })).status).toBe(403) + expect((await asKey('write', 'DELETE')).status).toBe(403) + expect((await asKey('admin', 'PATCH', { bio: 'b' })).status).toBe(200) + expect((await patch('u1', { kfOrgId: 'kf-other' })).status).toBe(403) + expect((await patch('u1', { kfOrgId: 'kf-1', displayName: 'Org One' })).status).toBe(200) + const [org] = await db + .select() + .from(schema.organization) + .where(eq(schema.organization.id, 'org1')) + expect(org).toMatchObject({ kfOrgId: 'kf-1', name: 'Org One' }) + expect((await patch('u1', { kfOrgId: null })).status).toBe(200) + + expect(await (await call('/api/accounts/available-kf-orgs', { user: 'u1' })).json()).toEqual([ + expect.objectContaining({ id: 'kf-1' }), + ]) + + await h.collection('c') + expect((await call('/api/accounts/org', { method: 'DELETE', user: 'u2' })).status).toBe(403) + expect((await call('/api/accounts/org', { method: 'DELETE', user: 'u1' })).status).toBe(409) + expect((await call('/api/accounts/u2', { method: 'DELETE', user: 'u2' })).status).toBe(409) + await db.delete(schema.collections) + expect((await asKey('write', 'DELETE')).status).toBe(403) + expect((await call('/api/accounts/org', { method: 'DELETE', user: 'u1' })).status).toBe(200) + expect( + await db.select().from(schema.member).where(eq(schema.member.organizationId, 'org1')), + ).toEqual([]) + }) +}) diff --git a/packages/server/test/admin.test.ts b/packages/server/test/admin.test.ts new file mode 100644 index 0000000..9e87398 --- /dev/null +++ b/packages/server/test/admin.test.ts @@ -0,0 +1,62 @@ +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { cleanup } from './harness.js' +import { setup } from './kf-app.js' + +afterAll(cleanup) + +// u1 is a steward in the fake KF Auth; u2 is not. +describe('steward and KF summary routes', () => { + it('lets only stewards read and set the featured explore lists', async () => { + const { call, user } = await setup() + await user('u1') + await user('u2') + for (const [path, field] of [ + ['/api/admin/explore-tags', 'tags'], + ['/api/admin/explore-collections', 'collections'], + ] as const) { + expect((await call(path)).status).toBe(401) + expect((await call(path, { user: 'u2' })).status).toBe(403) + expect(await (await call(path, { user: 'u1' })).json()).toEqual({ [field]: [] }) + const put = (json: unknown, u = 'u1') => call(path, { method: 'PUT', user: u, json }) + expect((await put({ [field]: ['a/b'] }, 'u2')).status).toBe(403) + expect((await put({ [field]: [1] })).status).toBe(422) + expect((await put({ [field]: ['org/c', 'x'] })).status).toBe(200) + expect(await (await call(path, { user: 'u1' })).json()).toEqual({ [field]: ['org/c', 'x'] }) + } + }) + + it('answers KF Auth with an org’s collections, with the internal key only', async () => { + const { h, call, db } = await setup() + await h.collection('c') + await db.update(schema.organization).set({ kfOrgId: 'kf-1' }) + const url = '/api/kf/summary?kf_org_id=kf-1' + + expect((await call(url)).status).toBe(401) + expect((await call(url, { authorization: 'Bearer wrong' })).status).toBe(401) + expect((await call('/api/kf/summary', { authorization: 'Bearer internal-key' })).status).toBe( + 400, + ) + const res = await (await call(url, { authorization: 'Bearer internal-key' })).json() + expect(res.orgs).toEqual([ + { + id: 'org1', + slug: 'org', + name: 'Org', + url: 'http://test/org', + collections: [ + expect.objectContaining({ + slug: 'c', + url: 'http://test/org/c', + stats: { versions: 0, records: 0, files: 0, bytes: 0 }, + }), + ], + }, + ]) + const none = await call('/api/kf/summary?kf_org_id=kf-none', { + authorization: 'Bearer internal-key', + }) + expect(await none.json()).toEqual({ orgs: [] }) + }) +}) diff --git a/src/lib/ark.test.ts b/packages/server/test/ark-lib.test.ts similarity index 96% rename from src/lib/ark.test.ts rename to packages/server/test/ark-lib.test.ts index 7625eed..16c5418 100644 --- a/src/lib/ark.test.ts +++ b/packages/server/test/ark-lib.test.ts @@ -1,7 +1,4 @@ -import { describe, expect, test, vi } from 'vitest' - -// Mock the DB module so the top-level import in ark.ts doesn't blow up -vi.mock('~/db/client.server.js', () => ({ db: {}, schema: {} })) +import { describe, expect, test } from 'vitest' import { BETANUMERIC, @@ -13,7 +10,7 @@ import { formatErcDate, nextShoulderCounter, parseArkPath, -} from '~/lib/ark' +} from '../src/lib/ark.js' describe('computeNcdaCheckChar', () => { test('returns a betanumeric character', () => { @@ -66,6 +63,11 @@ describe('collectionToArkId', () => { expect(collectionToArkId(uuid)).toBe(collectionToArkId(uuid)) }) + // ARKs v1 minted are in circulation; the derivation must never change. + test('matches the ids v1 minted', () => { + expect(collectionToArkId('550e8400-e29b-41d4-a716-446655440000')).toBe('f623mpkw3m') + }) + test('different UUIDs produce different IDs', () => { const a = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') const b = collectionToArkId('6ba7b810-9dad-11d1-80b4-00c04fd430c8') diff --git a/packages/server/test/ark.test.ts b/packages/server/test/ark.test.ts new file mode 100644 index 0000000..84201f7 --- /dev/null +++ b/packages/server/test/ark.test.ts @@ -0,0 +1,472 @@ +import { eq } from 'drizzle-orm' +import { Hono } from 'hono' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { arkRoutes, getOrMintShoulder } from '../src/api/ark.js' +import type { AppEnv } from '../src/app.js' +import * as schema from '../src/db/schema.js' +import { buildArkUrl, collectionToArkId, computeNcdaCheckChar } from '../src/lib/ark.js' +import { cleanup, type Harness, harness } from './harness.js' + +afterAll(cleanup) + +const Link = { + type: 'object', + properties: { title: { type: 'string' }, url: { type: 'string' } }, + required: ['title'], +} + +/** + * The ARK routes on the harness's ports, mounted as app.ts would: after the + * middleware that sets ports, config and principal. `x-test-user` signs in; + * `x-test-scope` makes it an API key with that scope. + */ +function arkApp(h: Harness) { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('ports', h.ports) + c.set('config', { appUrl: 'https://ul.test', deployment: 'test' }) + const user = c.req.header('x-test-user') + const scope = (c.req.header('x-test-scope') ?? 'session') as + | 'session' + | 'read' + | 'write' + | 'admin' + c.set('principal', user ? { userId: user, scope, collectionIds: null } : null) + await next() + }) + app.route('/', arkRoutes()) + return app +} + +interface Ctx { + h: Harness + user: string + collectionId: string + ark: ReturnType + /** fetch against the ARK routes. */ + req( + path: string, + init?: { method?: string; user?: string; scope?: string; json?: unknown }, + ): Promise + /** The path after "ark:NAAN/" for this collection, optionally a version and record. */ + name(semver?: string, type?: string, id?: string): string +} + +const base = '/api/collections/org/links' +let t: Ctx + +async function body(res: Response) { + return (await res.json()) as Record +} + +beforeAll(async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('links') + await h.ports.db + .update(schema.collections) + .set({ public: true }) + .where(eq(schema.collections.id, c.id)) + + let res = await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Link } }, + }) + const sid = (await body(res)).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [ + { id: 'a', type: 'Link', data: { title: 'A', url: 'https://example.org/a' } }, + { id: 'b', type: 'Link', data: { title: 'B', url: 'https://example.org/b' }, private: true }, + { id: 'evil', type: 'Link', data: { title: 'E', url: 'javascript:alert(1)' } }, + { id: 'none', type: 'Link', data: { title: 'N' } }, + ], + }) + res = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(res.status).toBe(201) + + const ark = arkApp(h) + const req: Ctx['req'] = async (path, init = {}) => { + const headers = new Headers() + if (init.user) headers.set('x-test-user', init.user) + if (init.scope) headers.set('x-test-scope', init.scope) + if (init.json !== undefined) headers.set('content-type', 'application/json') + return ark.fetch( + new Request(`http://test${path}`, { + method: init.method ?? 'GET', + headers, + ...(init.json !== undefined ? { body: JSON.stringify(init.json) } : {}), + }), + ) + } + t = { + h, + user, + collectionId: c.id, + ark, + req, + name: () => '', + } + + // Enabling the ARK mints the org's shoulder and the collection's id. + res = await req(`${base}/ark`, { method: 'PATCH', user, json: { enabled: true } }) + expect(await body(res)).toEqual({ ok: true }) + res = await req(`${base}/ark`, { user }) + const settings = await body(res) + t.name = (semver, type, id) => + new URL(buildArkUrl('12345', settings.shoulder, settings.arkId, semver, type, id)).pathname + .split('/') + .slice(2) + .join('/') +}) + +const addOrg2 = () => + t.h.ports.db + .insert(schema.organization) + .values({ id: 'org2', name: 'Two', slug: 'two' }) + .onConflictDoNothing() + +const resolve = (path: string, user?: string) => + t.req(`/api/ark/resolve?${new URLSearchParams({ path })}`, user ? { user } : {}) + +describe('collection ARK settings', () => { + it('reports the minted ARK to members', async () => { + const res = await t.req(`${base}/ark`, { user: t.user }) + expect(await body(res)).toEqual({ + enabled: true, + customUrl: null, + arkUrl: expect.stringMatching(/^https:\/\/underlay\.org\/ark:12345\/ulb\d/), + shoulder: expect.stringMatching(/^ulb\d$/), + arkId: collectionToArkId(t.collectionId), + }) + }) + + it('is for members only', async () => { + expect((await t.req(`${base}/ark`)).status).toBe(401) + expect((await t.req(`${base}/ark`, { user: 'stranger' })).status).toBe(403) + // A read-scoped key may read the settings but not change them. + expect((await t.req(`${base}/ark`, { user: t.user, scope: 'read' })).status).toBe(200) + const res = await t.req(`${base}/ark`, { + method: 'PATCH', + user: t.user, + scope: 'read', + json: { enabled: false }, + }) + expect(res.status).toBe(403) + expect((await t.req(`/api/collections/org/nope/ark`, { user: t.user })).status).toBe(404) + }) + + it('reports no ARK for a collection that has none', async () => { + await t.h.collection('bare') + const res = await t.req(`/api/collections/org/bare/ark`, { user: t.user }) + expect(await body(res)).toEqual({ + enabled: false, + customUrl: null, + arkUrl: null, + shoulder: null, + arkId: null, + }) + // A private collection stays hidden from non-members. + expect((await t.req(`/api/collections/org/bare/ark`, { user: 'stranger' })).status).toBe(404) + }) + + it('rejects custom URLs that are not http(s)', async () => { + for (const customUrl of ['javascript:alert(1)', '//evil.example', 'data:text/html,x']) { + const res = await t.req(`${base}/ark`, { method: 'PATCH', user: t.user, json: { customUrl } }) + expect(res.status).toBe(422) + } + const res = await t.req(`${base}/ark`, { method: 'PATCH', user: t.user, json: { enabled: 1 } }) + expect(res.status).toBe(400) + }) + + it('sets record-type redirect fields', async () => { + const rt = `${base}/ark/record-types` + let res = await t.req(rt, { method: 'PUT', user: t.user, json: { recordType: 'X' } }) + expect(res.status).toBe(400) + res = await t.req(rt, { + method: 'PUT', + user: t.user, + json: { recordType: 'X', redirectUrlField: 'href' }, + }) + expect(await body(res)).toEqual({ ok: true }) + res = await t.req(rt, { + method: 'PATCH', + user: t.user, + json: { recordType: 'Y', redirectUrlField: 'link' }, + }) + expect(res.status).toBe(200) + expect(await (await t.req(rt, { user: t.user })).json()).toEqual([ + { recordType: 'X', redirectUrlField: 'href' }, + { recordType: 'Y', redirectUrlField: 'link' }, + ]) + // v1's PATCH with null removes; DELETE does the same. + await t.req(rt, { + method: 'PATCH', + user: t.user, + json: { recordType: 'X', redirectUrlField: null }, + }) + await t.req(`${rt}/Y`, { method: 'DELETE', user: t.user }) + expect(await (await t.req(rt, { user: t.user })).json()).toEqual([]) + expect((await t.req(rt, { user: 'stranger' })).status).toBe(403) + res = await t.req(rt, { + method: 'PUT', + user: 'stranger', + json: { recordType: 'X', redirectUrlField: 'href' }, + }) + expect(res.status).toBe(403) + }) +}) + +describe('resolve', () => { + it('needs an ARK path', async () => { + expect((await t.req('/api/ark/resolve')).status).toBe(400) + expect((await resolve('nothing here')).status).toBe(400) + expect(await body(await resolve('ark:12345/'))).toEqual({ type: 'not_found' }) + }) + + it('resolves a collection ARK to its page', async () => { + const res = await resolve(`ark:12345/${t.name()}`) + expect(res.status).toBe(200) + const r = await body(res) + expect(r).toMatchObject({ + type: 'redirect', + url: '/org/links', + metadata: { + type: 'collection', + who: 'Org', + what: 'links', + where: `https://underlay.org/ark:12345/${t.name()}`, + naan: '12345', + collectionName: 'links', + ownerName: 'Org', + semver: 'v1.0.0', + arkUrl: `https://underlay.org/ark:12345/${t.name()}`, + }, + }) + expect(r.metadata.when).toMatch(/^\d{8}$/) + // The older "ark:/NAAN/" spelling is the same ARK. + expect((await body(await resolve(`ark:/12345/${t.name()}`))).url).toBe('/org/links') + }) + + it('resolves a version ARK, showing who pushed only to members', async () => { + const anon = await body(await resolve(`ark:12345/${t.name('v1.0.0')}`)) + expect(anon).toMatchObject({ + type: 'redirect', + url: '/org/links/v/1.0.0', + metadata: { type: 'version', what: 'links v1.0.0', semver: 'v1.0.0' }, + }) + expect(anon.metadata).not.toHaveProperty('pushedBy') + expect(anon.metadata).not.toHaveProperty('actorId') + const member = await body(await resolve(`ark:12345/${t.name('v1.0.0')}`, t.user)) + expect(member.metadata).toHaveProperty('pushedBy') + expect(member.metadata).toHaveProperty('actorId') + + expect((await resolve(`ark:12345/${t.name('v9.0.0')}`)).status).toBe(404) + }) + + it('rejects bad check characters and other orgs’ shoulders', async () => { + const name = t.name() + const bad = name.slice(0, -1) + (name.endsWith('b') ? 'c' : 'b') + expect((await resolve(`ark:12345/${bad}`)).status).toBe(404) + + await addOrg2() + const other = await getOrMintShoulder(t.h.ports.db, 'org2') + const arkId = collectionToArkId(t.collectionId) + const res = await resolve(`ark:12345/${other}${arkId}${computeNcdaCheckChar(arkId)}`) + expect(res.status).toBe(404) + }) + + it('resolves record ARKs of configured types, by set', async () => { + // Not configured yet: no record ARKs for the type. + expect((await resolve(`ark:12345/${t.name(undefined, 'Link', 'a')}`)).status).toBe(404) + await t.req(`${base}/ark/record-types`, { + method: 'PUT', + user: t.user, + json: { recordType: 'Link', redirectUrlField: 'url' }, + }) + + const pub = await body(await resolve(`ark:12345/${t.name(undefined, 'Link', 'a')}`)) + expect(pub).toMatchObject({ + type: 'redirect', + url: 'https://example.org/a', + metadata: { + type: 'record', + what: 'Link a in links', + semver: 'v1.0.0', + recordType: 'Link', + recordId: 'a', + schema: Link, + data: { title: 'A', url: 'https://example.org/a' }, + }, + }) + // Versioned record ARKs resolve at that version. + const at = await body(await resolve(`ark:12345/${t.name('v1.0.0', 'Link', 'a')}`)) + expect(at.url).toBe('https://example.org/a') + + // A private-set record resolves for members only. + const priv = `ark:12345/${t.name(undefined, 'Link', 'b')}` + expect(await body(await resolve(priv))).toEqual({ type: 'not_found' }) + expect(await body(await resolve(priv, 'stranger'))).toEqual({ type: 'not_found' }) + expect((await body(await resolve(priv, t.user))).url).toBe('https://example.org/b') + + // Missing records, and fields that aren't http(s) URLs, don't redirect. + expect((await resolve(`ark:12345/${t.name(undefined, 'Link', 'zzz')}`)).status).toBe(404) + for (const id of ['evil', 'none']) { + const res = await resolve(`ark:12345/${t.name(undefined, 'Link', id)}`) + expect(res.status).toBe(404) + expect(await body(res)).toEqual({ type: 'not_found', error: 'No URL found for this record' }) + } + }) + + it('hides private collections from non-members', async () => { + const db = t.h.ports.db + const set = (pub: boolean) => + db + .update(schema.collections) + .set({ public: pub }) + .where(eq(schema.collections.id, t.collectionId)) + await set(false) + try { + expect((await resolve(`ark:12345/${t.name()}`)).status).toBe(404) + expect((await resolve(`ark:12345/${t.name()}`, 'stranger')).status).toBe(404) + expect((await body(await resolve(`ark:12345/${t.name()}`, t.user))).url).toBe('/org/links') + } finally { + await set(true) + } + }) + + it('follows a custom URL and stops when disabled', async () => { + const patch = (json: unknown) => t.req(`${base}/ark`, { method: 'PATCH', user: t.user, json }) + await patch({ customUrl: 'https://custom.example/links' }) + try { + const r = await body(await resolve(`ark:12345/${t.name('v1.0.0')}`)) + expect(r).toMatchObject({ + url: 'https://custom.example/links', + metadata: { type: 'version', semver: 'v1.0.0' }, + }) + expect(r.metadata).not.toHaveProperty('pushedBy') + await patch({ enabled: false }) + expect((await resolve(`ark:12345/${t.name()}`)).status).toBe(404) + expect((await body(await t.req(`${base}/ark`, { user: t.user }))).enabled).toBe(false) + } finally { + await patch({ enabled: true, customUrl: '' }) + } + expect((await body(await resolve(`ark:12345/${t.name()}`))).url).toBe('/org/links') + }) +}) + +describe('/ark: URLs', () => { + it('answers the bare NAAN with its policy', async () => { + for (const p of ['/ark:12345', '/ark:12345/']) { + const res = await t.req(p) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toMatch(/^text\/plain/) + expect(await res.text()).toMatch( + /^The Underlay assigns identifiers within the ARK domain 12345/, + ) + } + }) + + it('redirects to the deployment origin', async () => { + const res = await t.req(`/ark:12345/${t.name()}`) + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('https://ul.test/org/links') + const v = await t.req(`/ark:12345/${t.name('v1.0.0')}`) + expect(v.headers.get('location')).toBe('https://ul.test/org/links/v/1.0.0') + }) + + it('redirects record ARKs to the record URL, by set', async () => { + const path = `/ark:12345/${t.name(undefined, 'Link', 'a')}` + expect((await t.req(path)).headers.get('location')).toBe('https://example.org/a') + const priv = `/ark:12345/${t.name(undefined, 'Link', 'b')}` + expect((await t.req(priv)).status).toBe(404) + expect((await t.req(priv, { user: t.user })).headers.get('location')).toBe( + 'https://example.org/b', + ) + }) + + it('answers ?info and ?? with an ERC, and ?json with the metadata', async () => { + for (const q of ['?info', '??']) { + const res = await t.req(`/ark:12345/${t.name('v1.0.0')}${q}`) + expect(res.status).toBe(200) + const erc = await res.text() + expect(erc).toMatch(/^erc:\nwho: Org\nwhat: links v1\.0\.0\nwhen: \d{8}\n/) + expect(erc).toContain(`where: https://underlay.org/ark:12345/${t.name('v1.0.0')}`) + expect(erc).toContain('where: https://underlay.org/ark:12345/') + } + const res = await t.req(`/ark:12345/${t.name()}?json`) + expect(res.headers.get('content-type')).toBe('application/json') + expect(await res.json()).toMatchObject({ type: 'collection', collectionName: 'links' }) + }) + + it('is a plain 404 for unknown ARKs', async () => { + for (const p of [ + '/ark:12345/nope', + '/ark:12345/ulb9xxxxx', + `/ark:12345/${t.name()}/Link/%E0%A4%A`, + ]) { + const res = await t.req(p) + expect(res.status).toBe(404) + expect(await res.text()).toBe('ARK not found') + } + }) +}) + +describe('organization NAAN', () => { + it('is set by org owners and admins, and used in ARK URLs', async () => { + const naanPath = '/api/accounts/org/ark' + const patch = (json: unknown, user?: string, scope?: string) => + t.req(naanPath, { + method: 'PATCH', + json, + ...(user ? { user } : {}), + ...(scope ? { scope } : {}), + }) + expect((await patch({ naan: '99999' })).status).toBe(401) + expect((await patch({ naan: '99999' }, 'stranger')).status).toBe(403) + expect((await patch({ naan: '99999' }, t.user, 'read')).status).toBe(403) + // An owner's write key acts as a member: only a session or an admin key may. + expect((await patch({ naan: '99999' }, t.user, 'write')).status).toBe(403) + expect((await patch({ naan: 'abc' }, t.user)).status).toBe(400) + expect((await patch({ naan: '12345' }, t.user)).status).toBe(409) + expect( + ( + await t.req('/api/accounts/nope/ark', { + method: 'PATCH', + user: t.user, + json: { naan: null }, + }) + ).status, + ).toBe(404) + + expect((await patch({ naan: '99999' }, t.user, 'admin')).status).toBe(200) + expect((await patch({ naan: '99999' }, t.user)).status).toBe(200) + try { + const settings = await body(await t.req(`${base}/ark`, { user: t.user })) + expect(settings.arkUrl).toMatch(/^https:\/\/underlay\.org\/ark:99999\//) + // Resolution answers with the org's NAAN, whichever the ARK was written with. + const r = await body(await resolve(`ark:12345/${t.name()}`)) + expect(r.metadata.naan).toBe('99999') + expect(r.metadata.arkUrl).toBe(`https://underlay.org/ark:99999/${t.name()}`) + // Another org can't claim it. + await addOrg2() + await t.h.ports.db + .insert(schema.member) + .values({ organizationId: 'org2', userId: t.user, role: 'admin' }) + expect( + ( + await t.req('/api/accounts/two/ark', { + method: 'PATCH', + user: t.user, + json: { naan: '99999' }, + }) + ).status, + ).toBe(409) + } finally { + await patch({ naan: null }, t.user) + } + }) +}) diff --git a/packages/server/test/auth.test.ts b/packages/server/test/auth.test.ts new file mode 100644 index 0000000..b17422e --- /dev/null +++ b/packages/server/test/auth.test.ts @@ -0,0 +1,427 @@ +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import { createApp } from '../src/app.js' +import { authenticator, createAuth, keyConfig } from '../src/auth/auth.js' +import * as schema from '../src/db/schema.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const Author = { type: 'object', properties: { name: { type: 'string' } } } + +describe('better-auth on SQLite', () => { + it('authenticates API keys with their scope and collection limits', async () => { + const h = await harness() + const user = await h.member('u1') + const c = await h.collection('authors') + await h.collection('other') + const auth = createAuth( + h.ports.db, + { + appUrl: 'http://test', + secret: 'test-secret-test-secret-test-secret', + oidc: { + issuerUrl: 'http://kf', + internalUrl: 'http://kf', + clientId: 'x', + clientSecret: 'y', + }, + }, + () => {}, + ) + const app = createApp(() => ({ + ports: h.ports, + config: { appUrl: 'http://test', deployment: 'test' }, + authenticate: authenticator(() => auth), + authHandler: (req) => auth.handler(req), + })) + const call = (path: string, key: string, body: unknown) => + app.fetch( + new Request(`http://test${path}`, { + method: 'POST', + headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }), + ) + + const write = await auth.api.createApiKey({ + body: { userId: user, metadata: { scope: 'write' } }, + }) + const read = await auth.api.createApiKey({ body: { userId: user } }) + const scoped = await auth.api.createApiKey({ + body: { userId: user, metadata: { scope: 'write', collectionIds: [c.id] } }, + }) + expect(write.key).toMatch(/^ul_/) + + expect( + (await call('/api/collections/org/authors/push', write.key, { schemas: { Author } })).status, + ).toBe(200) + expect( + (await call('/api/collections/org/authors/push', read.key, { schemas: { Author } })).status, + ).toBe(403) + // The scheme is case-insensitive. + const lower = await app.fetch( + new Request('http://test/api/collections/org/authors/push', { + method: 'POST', + headers: { authorization: `bearer ${write.key}`, 'content-type': 'application/json' }, + body: JSON.stringify({ schemas: { Author } }), + }), + ) + expect(lower.status).toBe(200) + expect( + (await call('/api/collections/org/authors/push', 'ul_bogus', { schemas: { Author } })).status, + ).toBe(401) + expect( + (await call('/api/collections/org/authors/push', scoped.key, { schemas: { Author } })).status, + ).toBe(200) + // A key limited to one collection is a stranger everywhere else. + expect( + (await call('/api/collections/org/other/push', scoped.key, { schemas: { Author } })).status, + ).toBe(404) + + // A key acts with at most its scope: write keys as members, admin keys with the role, + // unless confined to collections: such a key writes to them but never manages them. + const admin = await auth.api.createApiKey({ + body: { userId: user, metadata: { scope: 'admin' } }, + }) + const scopedAdmin = await auth.api.createApiKey({ + body: { userId: user, metadata: { scope: 'admin', collectionIds: [c.id] } }, + }) + const send = (method: string, path: string, key: string, body?: unknown) => + app.fetch( + new Request(`http://test${path}`, { + method, + headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, + ...(body ? { body: JSON.stringify(body) } : {}), + }), + ) + for (const k of [write.key, scoped.key, scopedAdmin.key]) { + expect( + (await send('PATCH', '/api/collections/org/authors', k, { public: true })).status, + ).toBe(403) + expect((await send('DELETE', '/api/collections/org/authors', k)).status).toBe(403) + } + expect( + (await send('PATCH', '/api/collections/org/authors', admin.key, { public: true })).status, + ).toBe(200) + + // The collection is public now. A Bearer header with no token is a key that + // doesn't verify, not an anonymous read (fetch trims `Bearer ` to `Bearer`). + const get = (headers: Record) => + app.fetch(new Request('http://test/api/collections/org/authors', { headers })) + expect((await get({})).status).toBe(200) + for (const authorization of ['Bearer', 'Bearer ', 'bearer \t ']) { + const res = await get({ authorization }) + expect(res.status).toBe(401) + expect(await res.json()).toMatchObject({ error: 'Invalid or expired API key' }) + } + + // Keys are checked against their row; last_request is written once, not per call. + const rows = async () => + (await h.ports.db.select().from(schema.apikey)).find( + (k) => k.start === write.key.slice(0, 6), + )! + const first = (await rows()).lastRequest + expect(first).not.toBeNull() + await call('/api/collections/org/authors/push', write.key, { schemas: { Author } }) + expect((await rows()).lastRequest).toEqual(first) + // A disabled key stops working once the isolate's copy of its row expires. + keyConfig.cacheMs = 0 + await h.ports.db + .update(schema.apikey) + .set({ enabled: false }) + .where(eq(schema.apikey.id, (await rows()).id)) + expect( + (await call('/api/collections/org/authors/push', write.key, { schemas: { Author } })).status, + ).toBe(401) + keyConfig.cacheMs = 30_000 + + // The auth routes are mounted. + const res = await app.fetch(new Request('http://test/api/auth/get-session')) + expect(res.status).toBeLessThan(500) + }) + + it('gives every new user a personal organization', async () => { + const h = await harness() + const auth = createAuth( + h.ports.db, + { + appUrl: 'http://test', + secret: 'test-secret-test-secret-test-secret', + oidc: { + issuerUrl: 'http://kf', + internalUrl: 'http://kf', + clientId: 'x', + clientSecret: 'y', + }, + }, + () => {}, + ) + const ctx = await auth.$context + await ctx.internalAdapter.createUser({ + name: 'Ada', + email: 'ada@example.org', + emailVerified: true, + }) + const orgs = await h.ports.db.select().from(schema.organization) + expect(orgs.map((o) => [o.slug, o.isDefault])).toEqual([['ada', true]]) + const members = await h.ports.db.select().from(schema.member) + expect(members[0]).toMatchObject({ organizationId: orgs[0]!.id, role: 'owner' }) + }) + + it('sends /login to KF Auth, and renders the page only to show an error', async () => { + const h = await harness() + const auth = createAuth( + h.ports.db, + { + appUrl: 'http://test', + secret: 'test-secret-test-secret-test-secret', + oidc: { + issuerUrl: 'http://kf', + internalUrl: 'http://kf', + clientId: 'x', + clientSecret: 'y', + }, + }, + () => {}, + ) + const app = createApp(() => ({ + ports: h.ports, + config: { appUrl: 'http://test', deployment: 'test' }, + authenticate: authenticator(() => auth), + authHandler: (req) => auth.handler(req), + renderPage: async () => new Response('page'), + })) + + const res = await app.fetch(new Request('http://test/login')) + expect(res.status).toBe(302) + const location = new URL(res.headers.get('location')!) + expect(location.origin + location.pathname).toBe('http://kf/api/auth/oauth2/authorize') + expect(location.searchParams.get('redirect_uri')).toBe( + 'http://test/api/auth/oauth2/callback/kf-auth', + ) + // The state cookie must reach the browser, or the callback can't be checked. + expect(res.headers.getSetCookie().some((c) => c.includes('better-auth.state'))).toBe(true) + + const failed = await app.fetch(new Request('http://test/login?error=auth_failed')) + expect(await failed.text()).toBe('page') + + // return_to survives sign-in when it is a path on this site, and only then. + const stateOf = async (q: string) => { + await h.ports.db.delete(schema.verification) + const r = await app.fetch(new Request(`http://test/login?return_to=${encodeURIComponent(q)}`)) + expect(r.status).toBe(302) + const [v] = await h.ports.db.select().from(schema.verification) + return JSON.parse(v!.value) as { callbackURL: string } + } + expect((await stateOf('/invitations/accept?token=t')).callbackURL).toBe( + '/invitations/accept?token=t', + ) + expect((await stateOf('//evil.example/x')).callbackURL).toBe('/dashboard') + expect((await stateOf('/\\evil.example')).callbackURL).toBe('/dashboard') + expect((await stateOf('https://evil.example/')).callbackURL).toBe('/dashboard') + }) +}) + +describe('KF org links on organizations', () => { + it('keeps a kfOrgId the creator belongs to, and replaces any other', async () => { + const h = await harness() + const kf = { + profile: async () => null, + role: async () => null, + orgs: async () => [], + entitled: async (_u: string, id: string) => id === 'kf-mine', + defaultOrgId: async () => 'kf-default', + isInternalCall: () => false, + } + const auth = createAuth( + h.ports.db, + { + appUrl: 'http://test', + secret: 'test-secret-test-secret-test-secret', + oidc: { + issuerUrl: 'http://kf', + internalUrl: 'http://kf', + clientId: 'x', + clientSecret: 'y', + }, + }, + () => {}, + kf, + ) + const ctx = await auth.$context + const user = await ctx.internalAdapter.createUser({ + name: 'Ada', + email: 'ada@example.org', + emailVerified: true, + }) + const create = (slug: string, kfOrgId: string) => + auth.api.createOrganization({ body: { name: slug, slug, userId: user.id, kfOrgId } as never }) + await create('mine', 'kf-mine') + await create('theirs', 'kf-theirs') + const orgs = await h.ports.db.select().from(schema.organization) + const link = (slug: string) => orgs.find((o) => o.slug === slug)?.kfOrgId + expect(link('mine')).toBe('kf-mine') + expect(link('theirs')).toBe('kf-default') + + // Logos come from the upload only: better-auth's fields are any URL. + await auth.api.createOrganization({ + body: { + name: 'logo', + slug: 'logo', + userId: user.id, + logo: 'https://evil.example/x.png', + } as never, + }) + const [logo] = await h.ports.db + .select() + .from(schema.organization) + .where(eq(schema.organization.slug, 'logo')) + expect(logo!.logo).toBeNull() + expect(logo!.avatarUrl).toBeNull() + await auth.api.createOrganization({ + body: { + name: 'av', + slug: 'av', + userId: user.id, + avatarUrl: 'https://evil.example/x.png', + } as never, + }) + const [av] = await h.ports.db + .select() + .from(schema.organization) + .where(eq(schema.organization.slug, 'av')) + expect(av!.avatarUrl).toBeNull() + + // better-auth's org update and delete are closed; the account routes apply the rules. + const app = createApp(() => ({ + ports: h.ports, + config: { appUrl: 'http://test', deployment: 'test' }, + authenticate: authenticator(() => auth), + authHandler: (req) => auth.handler(req), + })) + for (const route of ['update', 'delete']) { + const res = await app.fetch( + new Request(`http://test/api/auth/organization/${route}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ organizationId: 'org1' }), + }), + ) + expect(res.status).toBe(404) + } + }) +}) + +describe('agent links', () => { + it('serve push instructions for an agent key, and a 404 page for anything else', async () => { + const h = await harness() + const user = await h.member('u1') + const c = await h.collection('notes') + const other = await h.collection('other') + await h.ports.db + .update(schema.collections) + .set({ name: '' }) + .where(eq(schema.collections.id, c.id)) + const auth = createAuth( + h.ports.db, + { + appUrl: 'http://test', + secret: 'test-secret-test-secret-test-secret', + oidc: { + issuerUrl: 'http://kf', + internalUrl: 'http://kf', + clientId: 'x', + clientSecret: 'y', + }, + }, + () => {}, + ) + const app = createApp(() => ({ + ports: h.ports, + config: { appUrl: 'http://test', deployment: 'test' }, + authenticate: authenticator(() => auth), + renderPage: async () => new Response('page'), + })) + const key = (metadata: Record, userId = user) => + auth.api.createApiKey({ body: { userId, metadata } }) + // As the share panel makes them (web's share-panel.tsx). + const agent = (await key({ scope: 'write', collectionIds: [c.id], agentShare: true })).key + const page = (token: string) => app.fetch(new Request(`https://ul.example/agent/${token}`)) + + let res = await page(agent) + expect(res.status).toBe(200) + expect(res.headers.get('cache-control')).toBe('no-store') + expect(res.headers.get('referrer-policy')).toBe('no-referrer') + expect(res.headers.get('content-security-policy')).toContain("default-src 'none'") + let html = await res.text() + expect(html).toContain('https://ul.example/api/collections/org/notes/push') + expect(html).toContain('https://ul.example/llms.txt') + expect(html).toContain('no types yet') + expect(html).toContain('"base": null') + expect(html).not.toContain('', + ) + +/** + * The avatar routes on the harness's ports, mounted after middleware like + * app.ts's. `x-test-user` signs in; `x-test-scope` makes it an API key with that + * scope, `x-test-collections` limits it to collections, `x-test-org` makes it + * an org-owned key. + */ +function avatarApp(h: Harness) { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('ports', h.ports) + c.set('config', { appUrl: 'https://ul.test', deployment: 'test' }) + const user = c.req.header('x-test-user') + const scope = (c.req.header('x-test-scope') ?? 'session') as 'session' | 'read' | 'write' + const cols = c.req.header('x-test-collections') + const org = c.req.header('x-test-org') + c.set( + 'principal', + user + ? { + userId: user, + scope, + collectionIds: cols ? cols.split(',') : null, + ...(org ? { orgId: org } : {}), + } + : null, + ) + await next() + }) + app.route('/', avatarRoutes()) + return app +} + +let h: Harness +let app: ReturnType +let store: MemoryStore + +interface Opts { + user?: string + scope?: string + collections?: string + org?: string +} + +function headers(o: Opts) { + const hs = new Headers() + if (o.user) hs.set('x-test-user', o.user) + if (o.scope) hs.set('x-test-scope', o.scope) + if (o.collections) hs.set('x-test-collections', o.collections) + if (o.org) hs.set('x-test-org', o.org) + return hs +} + +async function upload(bytes: Uint8Array, type: string, o: Opts = { user: 'u1' }, name = 'logo') { + const form = new FormData() + form.append('avatar', new File([bytes as BlobPart], name, { type })) + // Sent as encoded bytes: when the route stops reading an oversize body, Node's + // FormData body stream (undici, Node 24.21) enqueues into the cancelled stream + // and throws an unhandled error. + const encoded = new Response(form) + const hs = headers(o) + hs.set('content-type', encoded.headers.get('content-type')!) + return app.fetch( + new Request('http://test/api/accounts/org/avatar', { + method: 'POST', + headers: hs, + body: await encoded.arrayBuffer(), + }), + ) +} + +function remove(o: Opts = { user: 'u1' }) { + return app.fetch( + new Request('http://test/api/accounts/org/avatar', { method: 'DELETE', headers: headers(o) }), + ) +} + +async function avatarUrl() { + const [org] = await h.ports.db + .select({ avatarUrl: schema.organization.avatarUrl }) + .from(schema.organization) + .where(eq(schema.organization.id, 'org1')) + return org!.avatarUrl +} + +async function setAvatarUrl(url: string | null) { + await h.ports.db + .update(schema.organization) + .set({ avatarUrl: url }) + .where(eq(schema.organization.id, 'org1')) +} + +beforeAll(async () => { + h = await harness() + await h.member('u1') + for (const [id, role] of [ + ['u-admin', 'admin'], + ['u-member', 'member'], + ] as const) { + await h.ports.db.insert(schema.user).values({ id, name: id, email: `${id}@example.org` }) + await h.ports.db.insert(schema.member).values({ organizationId: 'org1', userId: id, role }) + } + await h.ports.db.insert(schema.user).values({ id: 'stranger', name: 's', email: 's@x.org' }) + app = avatarApp(h) +}) + +beforeEach(async () => { + store = memoryStore() + h.ports.publicAssets = { store, baseUrl: BASE } + await setAvatarUrl(null) +}) + +describe('org avatars', () => { + it('an owner uploads a logo, then removes it', async () => { + const res = await upload(png(), 'image/png') + expect(res.status).toBe(200) + const body = (await res.json()) as { ok: boolean; avatarUrl: string } + expect(body.ok).toBe(true) + expect(body.avatarUrl).toMatch(new RegExp(`^${BASE}/avatars/org1/[0-9a-f]{64}\\.png$`)) + expect(await avatarUrl()).toBe(body.avatarUrl) + + const key = body.avatarUrl.slice(BASE.length + 1) + const obj = store.objects.get(key) + expect(obj?.contentType).toBe('image/png') + expect(obj?.cacheControl).toBe('public, max-age=31536000, immutable') + expect(obj?.bytes).toEqual(png()) + + const del = await remove() + expect(del.status).toBe(200) + expect(await del.json()).toEqual({ ok: true }) + expect(await avatarUrl()).toBeNull() + expect(store.objects.has(key)).toBe(false) + }) + + it('is mounted on the app', async () => { + const form = new FormData() + form.append('avatar', new File([png() as BlobPart], 'logo.png', { type: 'image/png' })) + const res = await h.request('/api/accounts/org/avatar', { + method: 'POST', + user: 'u1', + body: form, + }) + expect(res.status).toBe(200) + expect((await h.request('/api/accounts/org/avatar', { method: 'DELETE' })).status).toBe(401) + }) + + it('replacing a logo deletes the old one; re-uploading the same image keeps it', async () => { + const first = ((await (await upload(png(), 'image/png')).json()) as { avatarUrl: string }) + .avatarUrl + const again = ((await (await upload(png(), 'image/png')).json()) as { avatarUrl: string }) + .avatarUrl + expect(again).toBe(first) + expect(store.objects.has(first.slice(BASE.length + 1))).toBe(true) + + const second = ((await (await upload(gif(), 'image/gif')).json()) as { avatarUrl: string }) + .avatarUrl + expect(second).toMatch(/\.gif$/) + expect(store.objects.has(first.slice(BASE.length + 1))).toBe(false) + expect([...store.objects.keys()]).toEqual([second.slice(BASE.length + 1)]) + }) + + it('stores the type the bytes show, not the declared one', async () => { + const res = await upload(png(), 'image/jpeg', { user: 'u1' }, 'logo.jpg') + expect(res.status).toBe(200) + const { avatarUrl } = (await res.json()) as { avatarUrl: string } + expect(avatarUrl).toMatch(/\.png$/) + expect(store.objects.get(avatarUrl.slice(BASE.length + 1))?.contentType).toBe('image/png') + }) + + it('owners only: admins, members, strangers, read, scoped and org keys are refused', async () => { + expect((await upload(png(), 'image/png', {})).status).toBe(401) + for (const o of [ + { user: 'u-admin' }, + { user: 'u-member' }, + { user: 'stranger' }, + { user: 'u1', scope: 'read' }, + { user: 'u1', scope: 'write', collections: 'c1' }, + { user: 'u1', scope: 'write', org: 'some-other-org' }, + // Org-owned keys are not a user, as on the org's other account routes. + { user: 'k', scope: 'write', org: 'org1' }, + ]) { + expect((await upload(png(), 'image/png', o)).status, JSON.stringify(o)).toBe(403) + expect((await remove(o)).status, JSON.stringify(o)).toBe(403) + } + expect(store.objects.size).toBe(0) + // An owner's unscoped personal key can. + expect((await upload(png(), 'image/png', { user: 'u1', scope: 'write' })).status).toBe(200) + }) + + it("deleting an org deletes its logos in this deployment's bucket, and no one else's", async () => { + await h.ports.db.insert(schema.organization).values({ id: 'org2', name: 'Two', slug: 'two' }) + await h.ports.db + .insert(schema.member) + .values({ organizationId: 'org2', userId: 'u1', role: 'owner' }) + for (const key of ['avatars/org2/a.png', 'avatars/org2/b.gif', 'avatars/org1/keep.png']) + store.objects.set(key, { bytes: png(), contentType: 'image/png' }) + const res = await h.request('/api/accounts/two', { method: 'DELETE', user: 'u1' }) + expect(res.status).toBe(200) + expect([...store.objects.keys()]).toEqual(['avatars/org1/keep.png']) + }) + + it('404s an unknown org', async () => { + const res = await app.fetch( + new Request('http://test/api/accounts/nope/avatar', { + method: 'DELETE', + headers: headers({ user: 'u1' }), + }), + ) + expect(res.status).toBe(404) + }) + + it('rejects SVG and files whose bytes are not a raster image', async () => { + expect((await upload(svg(), 'image/svg+xml')).status).toBe(422) + expect((await upload(svg(), 'image/png', { user: 'u1' }, 'logo.png')).status).toBe(422) + const html = new TextEncoder().encode('') + expect((await upload(html, 'image/gif', { user: 'u1' }, 'logo.gif')).status).toBe(422) + // A real PNG declared as something that isn't an allowed image type. + expect((await upload(png(), 'text/html')).status).toBe(422) + expect(store.objects.size).toBe(0) + expect(await avatarUrl()).toBeNull() + }) + + it('413s a file over the cap, whether or not the body is', async () => { + // Just over: the body fits the multipart allowance, the file doesn't. + expect((await upload(png(1, AVATAR_MAX_BYTES + 1), 'image/png')).status).toBe(413) + // Far over: refused while reading the body. + expect((await upload(png(1, 3 * AVATAR_MAX_BYTES), 'image/png')).status).toBe(413) + expect((await upload(png(1, AVATAR_MAX_BYTES), 'image/png')).status).toBe(200) + }) + + it('400s a body that is not multipart, or has no file', async () => { + const res = await app.fetch( + new Request('http://test/api/accounts/org/avatar', { + method: 'POST', + headers: { ...Object.fromEntries(headers({ user: 'u1' })), 'content-type': 'image/png' }, + body: png() as BodyInit, + }), + ) + expect(res.status).toBe(400) + const form = new FormData() + form.append('name', 'not a file') + const empty = await app.fetch( + new Request('http://test/api/accounts/org/avatar', { + method: 'POST', + headers: headers({ user: 'u1' }), + body: form, + }), + ) + expect(empty.status).toBe(400) + }) + + it("never deletes a migrated logo on another host, or outside the org's folder", async () => { + // v1's URL, in production's bucket. Seed the same key here to prove it isn't touched. + const migrated = 'https://assets.underlay.org/avatars/org1/1700000000000.png' + store.objects.set('avatars/org1/1700000000000.png', { bytes: png(), contentType: 'image/png' }) + await setAvatarUrl(migrated) + expect((await upload(gif(), 'image/gif')).status).toBe(200) + expect(store.objects.has('avatars/org1/1700000000000.png')).toBe(true) + + await setAvatarUrl(migrated) + expect((await remove()).status).toBe(200) + expect(await avatarUrl()).toBeNull() + expect(store.objects.has('avatars/org1/1700000000000.png')).toBe(true) + + // Same host, another org's folder. + store.objects.set('avatars/org2/x.png', { bytes: png(), contentType: 'image/png' }) + await setAvatarUrl(`${BASE}/avatars/org2/x.png`) + expect((await remove()).status).toBe(200) + expect(store.objects.has('avatars/org2/x.png')).toBe(true) + }) + + it('deletes a v1-style logo under this deployment, as on prod', async () => { + store.objects.set('avatars/org1/1700000000000.png', { bytes: png(), contentType: 'image/png' }) + await setAvatarUrl(`${BASE}/avatars/org1/1700000000000.png`) + expect((await upload(gif(), 'image/gif')).status).toBe(200) + expect(store.objects.has('avatars/org1/1700000000000.png')).toBe(false) + }) + + it('503s, without changing anything, when the deployment has no public assets bucket', async () => { + delete h.ports.publicAssets + await setAvatarUrl('https://assets.underlay.org/avatars/org1/1.png') + expect((await upload(png(), 'image/png')).status).toBe(503) + expect((await remove()).status).toBe(503) + expect(await avatarUrl()).toBe('https://assets.underlay.org/avatars/org1/1.png') + }) +}) + +describe('sniffRaster', () => { + it('knows PNG, JPEG, GIF and WebP by their magic bytes', () => { + const riff = new TextEncoder().encode('RIFF\x00\x00\x00\x00WEBPVP8 ') + expect(sniffRaster(png())?.type).toBe('image/png') + expect(sniffRaster(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))?.ext).toBe('jpg') + expect(sniffRaster(gif())?.type).toBe('image/gif') + expect(sniffRaster(riff)?.type).toBe('image/webp') + expect(sniffRaster(svg())).toBeNull() + expect(sniffRaster(new TextEncoder().encode('RIFF\x00\x00\x00\x00WAVE'))).toBeNull() + expect(sniffRaster(new Uint8Array())).toBeNull() + }) +}) diff --git a/packages/server/test/cleanup.test.ts b/packages/server/test/cleanup.test.ts new file mode 100644 index 0000000..56d8c9c --- /dev/null +++ b/packages/server/test/cleanup.test.ts @@ -0,0 +1,744 @@ +import { createHash } from 'node:crypto' + +import { fsck, newSalt } from '@underlay/protocol' +import { eq, sql } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it } from 'vitest' + +import { cleanupConfig, emptyStats, getRun } from '../src/cleanup/config.js' +import { + closeWindow, + FenceError, + fenceConfig, + openWindow, + writeFence, +} from '../src/cleanup/fence.js' +import { cleanInternal } from '../src/cleanup/internal.js' +import { firstMarkState, markRepo, markStep, type MarkState } from '../src/cleanup/mark.js' +import { Marker, MarkSet } from '../src/cleanup/marks.js' +import { startRun } from '../src/cleanup/runs.js' +import * as schema from '../src/db/schema.js' +import { startUpload } from '../src/files/files.js' +import { isBulk } from '../src/jobs.js' +import { commitVersion } from '../src/versions/commit.js' +import { cleanup, type Harness, harness } from './harness.js' + +afterAll(cleanup) + +const sha = (s: string) => createHash('sha256').update(s).digest('hex') +const Doc = { type: 'object', properties: { title: { type: 'string' }, pdf: { type: 'object' } } } +const DAY = 24 * 60 * 60 * 1000 + +beforeEach(() => { + fenceConfig.slackMs = 50 + fenceConfig.pollMs = 10 +}) + +/** Push records (and a file) to org/; returns the session id. */ +async function push(h: Harness, user: string, slug: string, records: unknown[], file?: string) { + const base = `/api/collections/org/${slug}` + if (file) { + const put = await h.request(`${base}/files/${sha(file)}`, { method: 'PUT', user, body: file }) + expect(put.status).toBeLessThan(300) + } + const open = await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Doc } } }) + const sid = ((await open.json()) as { session_id: string }).session_id + await h.request(`${base}/push/${sid}/records`, { method: 'POST', user, ndjson: records }) + const commit = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(commit.status).toBe(201) + return sid +} + +const docs = (prefix: string, n: number, file?: string) => + Array.from({ length: n }, (_, i) => ({ + id: `${prefix}${i}`, + type: 'Doc', + data: { + title: `${prefix} ${i}`, + ...(file && i === 0 ? { pdf: { $file: `sha256:${sha(file)}` } } : {}), + }, + })) + +/** Make every object in the bucket look old enough to sweep. */ +function ageAll(h: Harness, ms = 3 * DAY) { + for (const o of h.bucket.objects.values()) o.modified = Date.now() - ms +} + +/** Backdate every tombstone past the grace period. */ +async function pastGrace(h: Harness) { + await h.ports.db + .update(schema.collectionTombstones) + .set({ deletedAt: new Date(Date.now() - cleanupConfig.tombstoneGraceMs - DAY) }) +} + +async function run(h: Harness, step: schema.CleanupStep, opts: { dryRun?: boolean } = {}) { + const r = await startRun(h.ports, step, { trigger: 'manual', ...opts }) + await h.drain() + return (await getRun(h.ports.db, r.id))! +} + +const keys = (h: Harness, part: string) => + [...h.bucket.objects.keys()].filter((k) => k.includes(part)) + +async function healthy(h: Harness, collectionId: string) { + const repo = await h.ports.stores.forCollection(collectionId) + const report = await fsck(repo, { + collectionId, + trustedKeys: [h.signer.publicKey], + fileBytes: true, + }) + expect(report.errors).toEqual([]) + return report +} + +describe('storage cleanup, step 1: sessions and uploads', () => { + it('deletes finished sessions past their grace, and abandoned uploads', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('a') + const done = await push(h, user, 'a', docs('d', 5)) + // An open session, untouched. + const open = await h.request('/api/collections/org/a/push', { + method: 'POST', + user, + json: { schemas: { Doc } }, + }) + const openId = ((await open.json()) as { session_id: string }).session_id + expect(keys(h, `sessions/${done}/`).length).toBeGreaterThan(0) + expect(keys(h, `sessions/${openId}/`).length).toBeGreaterThan(0) + + // Within the grace period nothing goes. + expect((await cleanInternal(h.ports)).stats.deleted).toEqual({}) + await h.ports.db + .update(schema.pushSessions) + .set({ expiresAt: new Date(Date.now() - 2 * DAY) }) + .where(eq(schema.pushSessions.id, done)) + + // A direct upload nobody completed. + const ticket = await startUpload(h.ports, c.id, { + hash: sha('never'), + size: 5, + mimeType: 'text/plain', + }) + const [upload] = await h.ports.db + .select() + .from(schema.fileUploads) + .where(eq(schema.fileUploads.id, ticket.id)) + await h.bucket.put(upload!.storageKey, 'never') + await h.ports.db + .update(schema.fileUploads) + .set({ createdAt: new Date(Date.now() - 2 * DAY) }) + .where(eq(schema.fileUploads.id, ticket.id)) + + // A dry run counts and leaves everything. + const dry = await run(h, 'internal', { dryRun: true }) + expect(dry.stats!.deleted.sessions!.objects).toBeGreaterThan(0) + expect(dry.stats!.deleted.uploads).toEqual({ objects: 1, bytes: 5 }) + expect(keys(h, `sessions/${done}/`).length).toBeGreaterThan(0) + + const r = await cleanInternal(h.ports) + expect(r.done).toBe(true) + expect(r.stats.deleted.sessions!.objects).toBeGreaterThan(0) + expect(keys(h, `sessions/${done}/`)).toEqual([]) + expect(keys(h, `sessions/${openId}/`).length).toBeGreaterThan(0) + expect(h.bucket.objects.has(upload!.storageKey)).toBe(false) + const [s] = await h.ports.db + .select() + .from(schema.pushSessions) + .where(eq(schema.pushSessions.id, done)) + expect(s!.cleanedAt).not.toBeNull() + expect( + await h.ports.db.select().from(schema.pushRuns).where(eq(schema.pushRuns.sessionId, done)), + ).toEqual([]) + const [u] = await h.ports.db + .select() + .from(schema.fileUploads) + .where(eq(schema.fileUploads.id, ticket.id)) + expect(u!.status).toBe('failed') + // Nothing left to do. + expect((await cleanInternal(h.ports)).stats.deleted).toEqual({}) + }) +}) + +describe('storage cleanup, steps 2 and 3: mark and sweep', () => { + it("frees a deleted collection's objects after its grace, and keeps what others share", async () => { + const h = await harness() + const user = await h.member() + const a = await h.collection('a') + await h.collection('c') + await push(h, user, 'a', docs('a', 50, 'shared pdf'), 'shared pdf') + // b is a fork of a: it shares a's trees outright. + const fork = await h.request('/api/collections/org/a/fork', { + method: 'POST', + user, + json: { targetOrg: 'org', slug: 'forked' }, + }) + expect(fork.status).toBeLessThan(300) + const [b] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.slug, 'forked')) + const cSession = await push(h, user, 'c', docs('c', 40, 'only c'), 'only c') + const [cRow] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.slug, 'c')) + expect(cSession).toBeTruthy() + for (const slug of ['a', 'c']) { + const del = await h.request(`/api/collections/org/${slug}`, { method: 'DELETE', user }) + expect(del.status).toBe(200) + } + ageAll(h) + const before = h.bucket.objects.size + const fileRows = async () => + (await h.ports.db.select({ hash: schema.files.hash }).from(schema.files)).map((f) => f.hash) + expect((await fileRows()).sort()).toEqual([sha('only c'), sha('shared pdf')].sort()) + + // In the grace period: nothing of a or c goes. + const mark1 = await run(h, 'mark') + expect(mark1.status).toBe('done') + expect(mark1.stats!.collections).toBe(3) // b live; a and c from their logs + const sweep1 = await run(h, 'sweep') + expect(sweep1.status).toBe('done') + expect(sweep1.stats!.deleted.files).toBeUndefined() + expect(sweep1.stats!.deleted.roots).toBeUndefined() + expect(sweep1.stats!.deleted.collections).toBeUndefined() + expect(h.bucket.objects.has(`repo/files/${sha('only c')}`)).toBe(true) + // Every version a deleted collection's log names still checks out in full. (Its + // derived trees, file reference counts and the cumulative public files tree, + // aren't kept: they're bookkeeping for new commits.) + const platform = await h.ports.stores.forLocation(schema.PLATFORM_LOCATION_ID) + for (const id of [a.id, cRow!.id]) { + const report = await fsck(platform, { + collectionId: id, + trustedKeys: [h.signer.publicKey], + fileBytes: true, + }) + expect(report.errors).toEqual([]) + } + + // Past it: a dry run counts, and deletes nothing. + await pastGrace(h) + await run(h, 'mark') + const dry = await run(h, 'sweep', { dryRun: true }) + expect(dry.stats!.deleted.files).toMatchObject({ objects: 1 }) + expect(dry.stats!.deleted.nodes!.objects).toBeGreaterThan(0) + expect(dry.stats!.deleted.collections!.objects).toBeGreaterThan(0) + expect(dry.stats!.windows).toBe(0) + expect(h.bucket.objects.has(`repo/files/${sha('only c')}`)).toBe(true) + // It keeps samples of what it would delete, a collection's prefix once. + expect(dry.stats!.samples!.files).toEqual([`files/${sha('only c')}`]) + expect(dry.stats!.samples!.collections!.sort()).toEqual( + [`collections/${a.id}/`, `collections/${cRow!.id}/`].sort(), + ) + expect(dry.stats!.samples!.nodes!.length).toBeGreaterThan(0) + + // The real sweep: c's own objects and file go; what b reaches stays. + const sweep = await run(h, 'sweep') + expect(sweep.status).toBe('done') + expect(sweep.stats!.windows).toBeGreaterThan(0) + expect(sweep.stats!.deleted.files).toMatchObject({ objects: 1 }) + expect(h.bucket.objects.has(`repo/files/${sha('only c')}`)).toBe(false) + expect(h.bucket.objects.has(`repo/files/${sha('shared pdf')}`)).toBe(true) + expect(await fileRows()).toEqual([sha('shared pdf')]) + expect(keys(h, `repo/collections/${cRow!.id}/`)).toEqual([]) + expect(keys(h, `repo/collections/${a.id}/`)).toEqual([]) + expect(h.bucket.objects.size).toBeLessThan(before) + await healthy(h, b!.id) + const read = await h.request('/api/collections/org/forked/versions/1/records.ndjson', { user }) + expect(read.status).toBe(200) + expect((await read.text()).trim().split('\n')).toHaveLength(50) + + // Schemas are never swept; unknown shapes are counted and kept. + expect(keys(h, 'repo/schemas/').length).toBeGreaterThan(0) + await h.bucket.put('repo/nodes/not-a-hash', 'x') + ageAll(h) + await run(h, 'mark') + const again = await run(h, 'sweep') + expect(again.stats!.unknown).toBeGreaterThan(0) + expect(h.bucket.objects.has('repo/nodes/not-a-hash')).toBe(true) + expect(again.stats!.deleted.nodes).toBeUndefined() + }) + + it('gives a collection deleted with its org a tombstone and its grace period', async () => { + const h = await harness() + const user = await h.member() + const a = await h.collection('a') + await push(h, user, 'a', docs('a', 20, 'org pdf'), 'org pdf') + // However the org row goes, its collections cascade with it. + await h.ports.db.delete(schema.organization).where(eq(schema.organization.id, 'org1')) + const [stone] = await h.ports.db + .select() + .from(schema.collectionTombstones) + .where(eq(schema.collectionTombstones.collectionId, a.id)) + expect(stone).toMatchObject({ organizationId: 'org1', slug: 'a', versions: 1 }) + expect(stone!.totalBytes).toBeGreaterThan(0) + ageAll(h) + const mark = await run(h, 'mark') + expect(mark.stats!.collections).toBe(1) + const sweep = await run(h, 'sweep') + expect(sweep.stats!.deleted.files).toBeUndefined() + expect(h.bucket.objects.has(`repo/files/${sha('org pdf')}`)).toBe(true) + await pastGrace(h) + await run(h, 'mark') + await run(h, 'sweep') + expect(h.bucket.objects.has(`repo/files/${sha('org pdf')}`)).toBe(false) + }) + + it("keeps a deleted collection's leftovers that a later push reuses", async () => { + const h = await harness() + const user = await h.member() + await h.collection('old') + const records = docs('r', 30, 'the pdf') + await push(h, user, 'old', records, 'the pdf') + expect((await h.request('/api/collections/org/old', { method: 'DELETE', user })).status).toBe( + 200, + ) + await pastGrace(h) + // The mark runs while nothing uses old's objects. + const mark = await run(h, 'mark') + expect(mark.status).toBe('done') + + // Then a new collection pushes the same records and file: writers skip the + // nodes, bodies and bytes that are already there. + const fresh = await h.collection('new') + const puts = h.bucket.puts + await push(h, user, 'new', records, 'the pdf') + expect(h.bucket.puts - puts).toBeLessThan(15) // mostly reused + ageAll(h) + + const sweep = await run(h, 'sweep') + expect(sweep.status).toBe('done') + await healthy(h, fresh.id) + expect(h.bucket.objects.has(`repo/files/${sha('the pdf')}`)).toBe(true) + }) + + it('keeps a large record a push uploaded while the sweep ran', async () => { + const h = await harness() + const user = await h.member() + // Over OUT_OF_LINE_BYTES: stored as its own records/ object. + const big = { id: 'big', type: 'Doc', data: { title: 'x'.repeat(70_000) } } + await h.collection('old') + await push(h, user, 'old', [big]) + const recordKeys = () => keys(h, 'repo/records/') + expect(recordKeys()).toHaveLength(1) + await h.request('/api/collections/org/old', { method: 'DELETE', user }) + await pastGrace(h) + await run(h, 'mark') + + // A new push uploads the same record and stays open while the sweep runs. + const fresh = await h.collection('new') + const base = '/api/collections/org/new' + const open = await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Doc } }, + }) + const sid = ((await open.json()) as { session_id: string }).session_id + await h.request(`${base}/push/${sid}/records`, { method: 'POST', user, ndjson: [big] }) + // Staged in the session's area, not written to the repository yet. + expect(keys(h, `internal/sessions/${sid}/records/`)).toHaveLength(1) + ageAll(h) + const sweep = await run(h, 'sweep') + expect(sweep.stats!.deleted.records).toMatchObject({ objects: 1 }) + expect(recordKeys()).toEqual([]) + + // The commit puts it back, under its fence. + const commit = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(commit.status).toBe(201) + expect(recordKeys()).toHaveLength(1) + await healthy(h, fresh.id) + }) + + it('gives the same result in many small jobs, shards and windows', async () => { + const saved = { ...cleanupConfig } + Object.assign(cleanupConfig, { + markNodeBudget: 1, + markCollections: 1, + sweepPages: 1, + windowObjects: 3, + sweepDeletes: 5, + }) + try { + const h = await harness() + const user = await h.member() + const keep = await h.collection('keep') + await push(h, user, 'keep', docs('k', 1500, 'kept file'), 'kept file') + const keep2 = await h.collection('keep2') + await push(h, user, 'keep2', docs('q', 800)) + for (const slug of ['gone1', 'gone2']) { + await h.collection(slug) + await push(h, user, slug, docs(slug, 1200, `${slug} file`), `${slug} file`) + await h.request(`/api/collections/org/${slug}`, { method: 'DELETE', user }) + } + await pastGrace(h) + ageAll(h) + const mark = await run(h, 'mark') + expect(mark.status).toBe('done') + expect(mark.seq).toBeGreaterThan(2) // it took more than one job + expect(keys(h, `internal/cleanup/${mark.id}/`).length).toBeGreaterThan(1) + const sweep = await run(h, 'sweep') + expect(sweep.status).toBe('done') + expect(sweep.stats!.windows).toBeGreaterThan(3) + await healthy(h, keep.id) + await healthy(h, keep2.id) + for (const slug of ['gone1', 'gone2']) + expect(h.bucket.objects.has(`repo/files/${sha(`${slug} file`)}`)).toBe(false) + // Nothing left for another pass. + await run(h, 'mark') + const again = await run(h, 'sweep') + expect(Object.keys(again.stats!.deleted)).toEqual([]) + await healthy(h, keep.id) + } finally { + Object.assign(cleanupConfig, saved) + } + }) + + it('waits while a push is committing', async () => { + const h = await harness() + const user = await h.member() + await h.collection('gone') + await push(h, user, 'gone', docs('g', 10)) + await h.request('/api/collections/org/gone', { method: 'DELETE', user }) + await pastGrace(h) + const c = await h.collection('busy') + const [session] = await h.ports.db + .insert(schema.pushSessions) + .values({ + collectionId: c.id, + userId: user, + status: 'committing', + finalizeStartedAt: new Date(), + expiresAt: new Date(Date.now() + DAY), + }) + .returning() + ageAll(h) + await run(h, 'mark') + const nodes = keys(h, 'repo/nodes/').length + const waiting = await run(h, 'sweep') + expect(waiting.status).toBe('waiting') + expect(keys(h, 'repo/nodes/').length).toBe(nodes) + + await h.ports.db + .update(schema.pushSessions) + .set({ status: 'failed' }) + .where(eq(schema.pushSessions.id, session!.id)) + await h.ports.db.update(schema.jobs).set({ runAt: new Date() }) + await h.drain() + const done = (await getRun(h.ports.db, waiting.id))! + expect(done.status).toBe('done') + expect(keys(h, 'repo/nodes/').length).toBeLessThan(nodes) + }) +}) + +describe('the mark’s budget', () => { + /** Every token a full, unbudgeted mark of these versions holds. */ + async function fullMark(h: Harness, hashes: string[]) { + const marks = new MarkSet() + const marker = new Marker(await markRepo(h.ports), marks) + for (const v of hashes) expect(await marker.version(v)).toBe(true) + return marks + } + + async function versionHashes(h: Harness, collectionId: string) { + const rows = await h.ports.db + .select({ hash: schema.versions.hash }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collectionId)) + return rows.map((r) => r.hash) + } + + it('stops inside one version’s tree and resumes there, marking only what is complete', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('big') + await push(h, user, 'big', docs('b', 4000, 'big file'), 'big file') + const [hash] = await versionHashes(h, c.id) + const full = await fullMark(h, [hash!]) + + const marks = new MarkSet() + const repo = await markRepo(h.ports) + let passes = 0 + for (;;) { + passes++ + const marker = new Marker(repo, marks, { budget: marks.reads + 1, concurrency: 1 }) + const size = marks.size + const done = await marker.version(hash!) + if (done) break + // Each pass gets further, however small its budget. + expect(marks.size).toBeGreaterThan(size) + // Stopped part way: the version isn't marked yet, nor is anything incomplete. + expect([...marks.hashes].some((t) => t.startsWith('v:'))).toBe(false) + expect(passes).toBeLessThan(500) + } + expect(passes).toBeGreaterThan(2) + expect([...marks.hashes].sort()).toEqual([...full.hashes].sort()) + }) + + it('carries a collection over many jobs and counts it once', async () => { + const saved = { ...cleanupConfig } + Object.assign(cleanupConfig, { markNodeBudget: 3, markConcurrency: 2 }) + try { + const h = await harness() + const user = await h.member() + const c = await h.collection('big') + await push(h, user, 'big', docs('b', 3000)) + await push(h, user, 'big', docs('n', 1500)) + const mark = await run(h, 'mark') + expect(mark.status).toBe('done') + expect(mark.seq).toBeGreaterThan(5) + expect(mark.stats).toMatchObject({ collections: 1, versions: 2 }) + const full = await fullMark(h, await versionHashes(h, c.id)) + expect(mark.stats!.marked).toBeGreaterThanOrEqual(full.size) + } finally { + Object.assign(cleanupConfig, saved) + } + }) + + it('saves checkpoints a killed job resumes from', async () => { + const saved = { ...cleanupConfig } + Object.assign(cleanupConfig, { markCheckpoint: 1, markConcurrency: 1 }) + try { + const h = await harness() + const user = await h.member() + const c = await h.collection('big') + await push(h, user, 'big', docs('b', 3000)) + await push(h, user, 'big', docs('n', 1500)) + const store = h.ports.stores.internal + const full = await fullMark(h, await versionHashes(h, c.id)) + + // The first job saves a few checkpoints, then dies. + const marks = new MarkSet() + let place: { state: MarkState; stats: ReturnType } | null = null + let checkpoints = 0 + await expect( + markStep(h.ports, marks, firstMarkState(), async (state, stats) => { + await marks.save(store, 'run', 'mark') + place = { state, stats } + if (++checkpoints === 5) throw new Error('killed') + }), + ).rejects.toThrow('killed') + expect(place!.state.phase).toBe('collections') + + // The next resumes from the last checkpoint with the shards saved so far. + const again = new MarkSet() + await again.load(store, 'run', 'mark') + expect(again.size).toBeGreaterThan(0) + const r = await markStep(h.ports, again, place!.state) + expect(r.state.phase).toBe('done') + expect(place!.stats.versions + r.stats.versions).toBe(2) + expect(place!.stats.collections + r.stats.collections).toBe(1) + for (const t of full.hashes) expect(again.hashes.has(t)).toBe(true) + } finally { + Object.assign(cleanupConfig, saved) + } + }) + + it('runs on the bulk queue', () => { + for (const step of ['internal', 'mark', 'sweep']) expect(isBulk(`cleanup.${step}`)).toBe(true) + }) + + it('shows progress on the run while a job works', async () => { + const saved = { ...cleanupConfig } + Object.assign(cleanupConfig, { markCheckpoint: 2, markConcurrency: 1 }) + try { + const h = await harness() + const user = await h.member() + await h.collection('big') + await push(h, user, 'big', docs('b', 3000)) + const r = await startRun(h.ports, 'mark', { trigger: 'manual' }) + // Watch the row while the job runs. + const seen: number[] = [] + const get = h.bucket.get.bind(h.bucket) + h.bucket.get = async (key: string) => { + const row = await getRun(h.ports.db, r.id) + if (row?.stats) seen.push(row.stats.marked) + return get(key) + } + await h.drain() + h.bucket.get = get + expect(Math.max(...seen)).toBeGreaterThan(0) + expect((await getRun(h.ports.db, r.id))!.status).toBe('done') + } finally { + Object.assign(cleanupConfig, saved) + } + }) +}) + +describe('what the mark trusts', () => { + it("doesn't take a file whose bytes are a node for that node", async () => { + const h = await harness() + const user = await h.member() + const target = await h.collection('target') + await push(h, user, 'target', docs('t', 3000)) + const repo = await h.ports.stores.forCollection(target.id) + const [v] = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, target.id)) + const treeRoot = (await repo.root(v!.hash)).public.types.Doc!.root! + const nodeJson = await repo.nodeJson(treeRoot) + expect(sha(nodeJson)).toBe(treeRoot) + + // A collection walked first (its id sorts first) holds that JSON as a file. + await h.ports.db.insert(schema.collections).values({ + id: '00000000-0000-0000-0000-000000000000', + organizationId: 'org1', + slug: 'first', + name: 'first', + privateSalt: newSalt(), + }) + await h.ports.db.insert(schema.placements).values({ + collectionId: '00000000-0000-0000-0000-000000000000', + locationId: schema.PLATFORM_LOCATION_ID, + role: 'primary', + sets: 'public+private', + }) + await push(h, user, 'first', docs('f', 1, nodeJson), nodeJson) + ageAll(h) + await run(h, 'mark') + const sweep = await run(h, 'sweep') + expect(sweep.status).toBe('done') + await healthy(h, target.id) + }) + + it('keeps objects whose write time the store can’t give', async () => { + const h = await harness() + const garbage = `repo/nodes/${sha('nobody uses this')}` + // Set directly: no write time. + h.bucket.objects.set(garbage, { bytes: new Uint8Array(4), contentType: null }) + await run(h, 'mark') + const sweep = await run(h, 'sweep') + expect(sweep.status).toBe('done') + expect(h.bucket.objects.has(garbage)).toBe(true) + ageAll(h) + await run(h, 'mark') + await run(h, 'sweep') + expect(h.bucket.objects.has(garbage)).toBe(false) + }) + + it('starts no mark or sweep while paused', async () => { + const h = await harness() + await h.ports.db + .insert(schema.instanceSettings) + .values({ key: 'cleanup_paused', value: true, updatedAt: new Date() }) + await expect(startRun(h.ports, 'mark', { trigger: 'manual' })).rejects.toThrow(/paused/) + // Sessions and uploads still go. + expect((await run(h, 'internal')).status).toBe('done') + }) +}) + +describe('the write fence', () => { + it('makes a write phase that spans a deletion window redo', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('m') + await push(h, user, 'm', docs('m', 3)) + const stale = await writeFence(h.ports.db) + const w = (await openWindow(h.ports.db, 'test', 10_000))! + expect(w.epoch).toBe(stale + 1) + // A second window can't open over it. + expect(await openWindow(h.ports.db, 'other', 10_000)).toBeNull() + await closeWindow(h.ports.db, w) + + // A commit whose writes began before the window can't publish. + const [v] = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, c.id)) + await expect( + commitVersion(h.ports, { + collectionId: c.id, + fence: stale, + base: v!, + types: [], + metadata: { title: 'x' }, + }), + ).rejects.toBeInstanceOf(FenceError) + // The metadata route redoes its write under a fresh fence. + const meta = await h.request('/api/collections/org/m/metadata', { + method: 'POST', + user, + json: { title: 'After' }, + }) + expect(meta.status).toBe(201) + }) + + it('holds writers while a window is open', async () => { + const h = await harness() + const w = (await openWindow(h.ports.db, 'test', 80))! + const t = Date.now() + const epoch = await writeFence(h.ports.db) + // Waited out the window (80 ms) and its slack (50 ms). + expect(Date.now() - t).toBeGreaterThanOrEqual(100) + expect(epoch).toBe(w.epoch) + await closeWindow(h.ports.db, w) + expect(await writeFence(h.ports.db)).toBe(w.epoch + 1) + }) + + it('puts back a file the sweep deleted when an upload reuses it', async () => { + const h = await harness() + const user = await h.member() + await h.collection('f') + const bytes = 'reused bytes' + // A files row whose bytes the sweep deleted (rows first, then bytes, so + // this only happens to a writer that read the row before the window). + await h.request(`/api/collections/org/f/files/${sha(bytes)}`, { + method: 'PUT', + user, + body: bytes, + }) + const key = `repo/files/${sha(bytes)}` + expect(h.bucket.objects.has(key)).toBe(true) + await h.ports.db.delete(schema.files).where(eq(schema.files.hash, sha(bytes))) + h.bucket.objects.delete(key) + await h.request(`/api/collections/org/f/files/${sha(bytes)}`, { + method: 'PUT', + user, + body: bytes, + }) + expect(h.bucket.objects.has(key)).toBe(true) + const [row] = await h.ports.db + .select({ n: sql`count(*)` }) + .from(schema.files) + .where(eq(schema.files.hash, sha(bytes))) + expect(row!.n).toBe(1) + }) +}) + +describe('cleanup for stewards', () => { + it('lists runs, starts them, and switches the weekly run', async () => { + const { setup } = await import('./kf-app.js') + const { h, call, user } = await setup() + await user('u1') + await user('u2') + expect((await call('/api/admin/cleanup', { user: 'u2' })).status).toBe(403) + const start = await call('/api/admin/cleanup/runs', { + method: 'POST', + user: 'u1', + json: { step: 'internal', dryRun: true }, + }) + expect(start.status).toBe(202) + // No mark yet, so no sweep. + const sweep = await call('/api/admin/cleanup/runs', { + method: 'POST', + user: 'u1', + json: { step: 'sweep' }, + }) + expect(sweep.status).toBe(409) + await h.drain() + const auto = await call('/api/admin/cleanup/auto', { + method: 'PUT', + user: 'u1', + json: { enabled: true }, + }) + expect(auto.status).toBe(200) + const page = (await (await call('/api/admin/cleanup', { user: 'u1' })).json()) as { + runs: { step: string; status: string; dryRun: boolean }[] + auto: boolean + fence: { windowOpen: boolean } + } + expect(page.auto).toBe(true) + expect(page.fence.windowOpen).toBe(false) + expect(page.runs[0]).toMatchObject({ step: 'internal', status: 'done', dryRun: true }) + }) +}) diff --git a/packages/server/test/collection-info.test.ts b/packages/server/test/collection-info.test.ts new file mode 100644 index 0000000..678642d --- /dev/null +++ b/packages/server/test/collection-info.test.ts @@ -0,0 +1,242 @@ +import { createHash } from 'node:crypto' + +import { keys, openRepo, PrefixedStore, readCollectionInfo, s3Store } from '@underlay/protocol' +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import { type FakeS3, startFakeS3 } from '../../protocol/test/stores/fake-s3.js' +import * as schema from '../src/db/schema.js' +import { INFO_BACKFILL_MARKER, queueInfoBackfill } from '../src/versions/collection-info.js' +import { cleanup, type Harness, harness } from './harness.js' + +const fakes: FakeS3[] = [] +afterAll(async () => { + for (const f of fakes.splice(0)) await f.close() + await cleanup() +}) + +const sha = (s: string) => createHash('sha256').update(s).digest('hex') +const Book = { + type: 'object', + properties: { title: { type: 'string' }, cover: { type: 'object' } }, +} +const book = (id: string, data: Record = { title: id }, extra = {}) => ({ + id, + type: 'Book', + data, + ...extra, +}) + +async function push(h: Harness, user: string, path: string, open: object, records: unknown[]) { + const res = await h.request(`${path}/push`, { method: 'POST', user, json: open }) + const sid = ((await res.json()) as { session_id: string }).session_id + await h.request(`${path}/push/${sid}/records`, { method: 'POST', user, ndjson: records }) + const commit = await h.request(`${path}/push/${sid}/commit`, { method: 'POST', user }) + expect(commit.status).toBe(201) + return (await commit.json()) as { semver: string; hash: string } +} + +/** A collection with one version, public or not. */ +async function setup(opts: { public?: boolean } = {}) { + const h = await harness() + const user = await h.member() + const c = await h.collection('books') + if (opts.public) { + await h.ports.db + .update(schema.collections) + .set({ public: true }) + .where(eq(schema.collections.id, c.id)) + } + const path = '/api/collections/org/books' + const v1 = await push(h, user, path, { schemas: { Book } }, [book('a'), book('b')]) + const repo = await h.ports.stores.forCollection(c.id) + return { h, user, c, path, v1, repo } +} + +describe('collection.json', () => { + it('a commit writes the owner, visibility and description (spec 11.1)', async () => { + const { h, c, repo } = await setup() + const info = await readCollectionInfo(repo, c.id) + expect(info).toMatchObject({ + id: c.id, + owner: { id: 'org1', did: null, handle: 'org', name: 'Org' }, + slug: 'books', + name: 'books', + description: null, + visibility: 'private', + ark: null, + }) + expect(info!.keys.map((k) => k.id)).toEqual([h.signer.keyId]) + }) + + it('is rewritten when the collection or its owner changes without a version', async () => { + const { h, user, c, path, repo } = await setup() + const patch = await h.request(path, { + method: 'PATCH', + user, + json: { name: 'Books', slug: 'library', public: true }, + }) + expect(patch.status).toBe(200) + await h.drain() + let info = await readCollectionInfo(repo, c.id) + expect(info).toMatchObject({ slug: 'library', name: 'Books', visibility: 'public' }) + // The keys that signed the log stay listed. + expect(info!.keys.map((k) => k.id)).toEqual([h.signer.keyId]) + + const rename = await h.request('/api/accounts/org', { + method: 'PATCH', + user, + json: { slug: 'the-press', displayName: 'The Press' }, + }) + expect(rename.status).toBe(200) + await h.drain() + info = await readCollectionInfo(repo, c.id) + expect(info!.owner).toEqual({ id: 'org1', did: null, handle: 'the-press', name: 'The Press' }) + + // Enabling the ARK puts it in the file. + const ark = await h.request('/api/collections/the-press/library/ark', { + method: 'PATCH', + user, + json: { enabled: true }, + }) + expect(ark.status).toBe(200) + await h.drain() + info = await readCollectionInfo(repo, c.id) + expect(info!.ark).toMatch(/^ark:\d+\//) + }) + + it('a collection with no version yet gets no file until its first commit', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('empty') + const repo = await h.ports.stores.forCollection(c.id) + await h.request('/api/collections/org/empty', { method: 'PATCH', user, json: { name: 'E' } }) + await h.drain() + expect(await readCollectionInfo(repo, c.id)).toBeNull() + }) + + it('a mirror holding a version gets each rewrite', async () => { + const { h, user, c, path } = await setup() + const f = await startFakeS3('bucket', 0) + fakes.push(f) + const loc = await h.request('/api/orgs/org/locations', { + method: 'POST', + user, + json: { + name: 'm', + endpoint: f.url, + bucket: 'bucket', + prefix: 'm', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + }, + }) + const locationId = ((await loc.json()) as { location: { id: string } }).location.id + await h.request(`${path}/placements`, { + method: 'POST', + user, + json: { locationId, sets: 'public' }, + }) + await h.drain() + const mirror = openRepo( + new PrefixedStore( + s3Store({ + endpoint: f.url, + bucket: 'bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + }), + 'm', + ), + ) + expect((await readCollectionInfo(mirror, c.id))!.visibility).toBe('private') + await h.request(path, { method: 'PATCH', user, json: { public: true } }) + await h.drain() + expect((await readCollectionInfo(mirror, c.id))!.visibility).toBe('public') + }) + + it('the backfill rewrites files earlier code wrote, once', async () => { + const { h, c, repo } = await setup() + // What the code before 2026-10-05 wrote: the owner by slug, no visibility. + const earlier = JSON.stringify({ + id: c.id, + owner: 'org', + slug: 'books', + name: 'books', + keys: (await readCollectionInfo(repo, c.id))!.keys, + }) + await repo.blobs.put(keys.collection(c.id), earlier) + await queueInfoBackfill(h.ports) + await h.drain() + expect(await readCollectionInfo(repo, c.id)).toMatchObject({ + owner: { id: 'org1', did: null, handle: 'org', name: 'Org' }, + visibility: 'private', + }) + expect(await h.ports.stores.internal.head(INFO_BACKFILL_MARKER)).toBeTruthy() + // Once: a second call queues nothing. + await queueInfoBackfill(h.ports) + expect(await h.drain()).toBe(0) + }) +}) + +describe('collection URLs (spec 11.3.1)', () => { + it('the id form serves the same collection, and every response names it', async () => { + const { h, c, path, v1 } = await setup({ public: true }) + const byHandle = await h.request(path) + expect(byHandle.headers.get('x-underlay-collection')).toBe(c.id) + const body = (await byHandle.json()) as Record + expect(body).toMatchObject({ + id: c.id, + owner: { id: 'org1', did: null, handle: 'org', name: 'Org' }, + slug: 'books', + visibility: 'public', + versionCount: 1, + head: { semver: v1.semver, hash: v1.hash }, + }) + + const byId = await h.request(`/api/collections/_/${c.id}`) + expect(byId.status).toBe(200) + expect(byId.headers.get('x-underlay-collection')).toBe(c.id) + expect(((await byId.json()) as { head: unknown }).head).toEqual(body.head) + + const records = await h.request(`/api/collections/_/${c.id}/versions/latest/records`) + expect(records.headers.get('x-underlay-collection')).toBe(c.id) + expect(((await records.json()) as { records: unknown[] }).records).toHaveLength(2) + const ndjson = await h.request(`/api/collections/_/${c.id}/versions/latest/records.ndjson`) + expect(ndjson.headers.get('x-underlay-collection')).toBe(c.id) + expect((await ndjson.text()).trim().split('\n')).toHaveLength(2) + }) + + it('DIDs and domain handles name no collection yet, and a hidden one sends no id', async () => { + const { h, c } = await setup() + for (const owner of ['did:plc:abc', 'press.mit.edu']) { + expect((await h.request(`/api/collections/${owner}/books`)).status).toBe(404) + } + // Private: anonymous callers get the same 404 by either form, and no id. + for (const url of ['/api/collections/org/books', `/api/collections/_/${c.id}`]) { + const res = await h.request(url) + expect(res.status).toBe(404) + expect(res.headers.get('x-underlay-collection')).toBeNull() + } + expect((await h.request(`/api/collections/_/not-an-id`)).status).toBe(404) + }) + + it("a first version's diff counts its files as added", async () => { + const h = await harness() + const user = await h.member() + await h.collection('files') + const path = '/api/collections/org/files' + const cover = 'cover bytes' + await h.request(`${path}/files/${sha(cover)}`, { method: 'PUT', user, body: cover }) + await push(h, user, path, { schemas: { Book } }, [ + book('a', { title: 'a', cover: { $file: `sha256:${sha(cover)}` } }), + ]) + const diff = (await (await h.request(`${path}/versions/latest/diff`, { user })).json()) as { + from: string | null + added: unknown[] + meta: { filesAdded: number } + } + expect(diff).toMatchObject({ from: null, meta: { filesAdded: 1 } }) + expect(diff.added).toHaveLength(1) + }) +}) diff --git a/packages/server/test/commit.test.ts b/packages/server/test/commit.test.ts new file mode 100644 index 0000000..7d82ff5 --- /dev/null +++ b/packages/server/test/commit.test.ts @@ -0,0 +1,615 @@ +import { + applyFileSet, + type Change, + compareUtf8, + fileTree, + getEntry, + hashRecord, + hashSchema, + iterate, + MissingFilesError, + readHead, + type RecordEntry, + recordTree, + Repo, + RepoSource, + tracksTypes, + utf8ByteLength, + verifyLog, +} from '@underlay/protocol' +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it, vi } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { type BaseVersion, commitVersion, type TypeInput } from '../src/versions/commit.js' +import { publishVersion } from '../src/versions/publish.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const authorSchema = { type: 'object', properties: { name: { type: 'string' }, photo: {} } } +const secretSchema = { type: 'object', private: true, properties: { note: { type: 'string' } } } + +const rec = (type: string, id: string, data: unknown): RecordEntry => { + const { hash, canonical } = hashRecord(id, type, data) + return { key: id, hash, size: utf8ByteLength(canonical), body: canonical } +} +const up = (type: string, id: string, data: unknown): Change => ({ + key: id, + entry: rec(type, id, data), +}) +const del = (id: string): Change => ({ key: id, entry: null }) +const sorted = (cs: Change[]) => cs.sort((a, b) => compareUtf8(a.key, b.key)) + +const type = ( + slug: string, + s: Record, + pub: Change[] | null, + priv: Change[] | null = null, +): TypeInput => ({ + slug, + schema: s, + schemaHash: hashSchema(s), + public: pub && sorted(pub), + private: priv && sorted(priv), +}) + +const baseOf = (v: typeof schema.versions.$inferSelect): BaseVersion => ({ + id: v.id, + seq: v.seq, + semver: v.semver, + hash: v.hash, + publicRefsRoot: v.publicRefsRoot, + privateRefsRoot: v.privateRefsRoot, +}) + +async function collect(it: AsyncIterable): Promise { + const out: T[] = [] + for await (const x of it) out.push(x) + return out +} + +const FILE = 'f'.repeat(64) + +describe('commitVersion', () => { + it('commits, publishes by CAS, and writes the signed log', async () => { + const h = await harness() + const c = await h.collection() + const authors = Array.from({ length: 3000 }, (_, i) => up('Author', `a${i}`, { name: `A${i}` })) + const r1 = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [type('Author', authorSchema, authors, [up('Author', 'hidden', { name: 'H' })])], + metadata: { title: 'Test', tags: ['x'] }, + message: 'first', + }) + expect(r1.status).toBe('committed') + if (r1.status !== 'committed') return + const v1 = r1.version + expect(v1).toMatchObject({ + seq: 1, + semver: 'v1.0.0', + recordCount: 3001, + publicRecordCount: 3000, + hasPrivate: true, + }) + expect(v1.hash).toMatch(/^ulv2:[0-9a-f]{64}$/) + const [col] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, c.id)) + expect(col!.headVersionId).toBe(v1.id) + expect(col!.summary).toEqual({ title: 'Test', tags: ['x'] }) + + const repo = await h.ports.stores.forCollection(c.id) + const root = await repo.root(v1.hash) + expect(root.public.types.Author!.count).toBe(3000) + expect(root.private).toMatch(/^[0-9a-f]{64}$/) + const priv = await repo.privateSet(root.private!) + expect(priv.types.Author!.count).toBe(1) + expect(priv.salt).toBe(c.privateSalt) + // Public readers can't see the hidden record: it's not in the public tree. + const source = new RepoSource(recordTree, repo) + expect(await getEntry(source, root.public.types.Author!.root, 'hidden')).toBe(null) + + const { entries } = await verifyLog(repo, c.id, [h.signer.publicKey]) + expect(entries.map((e) => [e.seq, e.versionHash, e.message])).toEqual([[1, v1.hash, 'first']]) + + // A small change: minor bump, O(changes) writes. + const putsBefore = h.bucket.puts + const r2 = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(v1), + types: [ + type('Author', authorSchema, [ + up('Author', 'a5', { name: 'changed' }), + del('a6'), + up('Author', 'zz', { name: 'Z' }), + ]), + ], + metadata: { title: 'Test', tags: ['x'] }, + }) + expect(r2.status).toBe('committed') + if (r2.status !== 'committed') return + expect(r2.version).toMatchObject({ + seq: 2, + semver: 'v1.1.0', + recordCount: 3001, + changes: { added: 1, removed: 1, updated: 1 }, + }) + expect(h.bucket.puts - putsBefore).toBeLessThan(30) + expect((await readHead(repo, c.id))!.seq).toBe(2) + + // The same content again: no change. A metadata edit: patch. + const r3 = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(r2.version), + types: [type('Author', authorSchema, null)], + metadata: { title: 'Test', tags: ['x'] }, + }) + expect(r3.status).toBe('no_changes') + const r4 = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(r2.version), + types: [type('Author', authorSchema, null)], + metadata: { title: 'Renamed' }, + }) + expect(r4.status === 'committed' && r4.version.semver).toBe('v1.1.1') + if (r4.status !== 'committed') return + const root4 = await repo.root(r4.version.hash) + expect(root4.public).toEqual((await repo.root(r2.version.hash)).public) + await expect(verifyLog(repo, c.id, [h.signer.publicKey])).resolves.toBeTruthy() + expect(await h.drain()).toBeGreaterThan(0) + }) + + it('refuses a commit whose base is no longer the head', async () => { + const h = await harness() + const c = await h.collection() + const r1 = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [type('Author', authorSchema, [up('Author', 'a', { name: 'A' })])], + metadata: null, + }) + if (r1.status !== 'committed') throw new Error(r1.status) + const fromV1 = (name: string) => + commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(r1.version), + types: [type('Author', authorSchema, [up('Author', 'a', { name })])], + metadata: null, + }) + const [x, y] = [await fromV1('X'), await fromV1('Y')] + expect(x.status).toBe('committed') + expect(y.status).toBe('conflict') + const versions = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, c.id)) + expect(versions.map((v) => v.seq).sort()).toEqual([1, 2]) + // A commit racing from an empty collection also loses cleanly. + const fromNull = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [type('Author', authorSchema, [up('Author', 'b', { name: 'B' })])], + metadata: null, + }) + expect(fromNull.status).toBe('conflict') + }) + + it('moves types between sets and removes types', async () => { + const h = await harness() + const c = await h.collection() + const repo = await h.ports.stores.forCollection(c.id) + const v1 = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [ + type('Author', authorSchema, [ + up('Author', 'a', { name: 'A' }), + up('Author', 'b', { name: 'B' }), + ]), + type('Secret', secretSchema, null, [up('Secret', 's', { note: 'shh' })]), + ], + metadata: null, + }) + if (v1.status !== 'committed') throw new Error(v1.status) + const root1 = await repo.root(v1.version.hash) + expect(Object.keys(root1.public.types)).toEqual(['Author']) + expect(Object.keys((await repo.privateSet(root1.private!)).types)).toEqual(['Secret']) + + // Author becomes private: its tree moves to the private set unchanged. + const privAuthor = { ...authorSchema, private: true } + const v2 = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(v1.version), + types: [type('Author', privAuthor, null), type('Secret', secretSchema, null)], + metadata: null, + }) + if (v2.status !== 'committed') throw new Error(v2.status) + expect(v2.version.semver).toBe('v2.0.0') + const root2 = await repo.root(v2.version.hash) + const priv2 = await repo.privateSet(root2.private!) + expect(root2.public.types).toEqual({}) + expect(priv2.types.Author!.root).toBe(root1.public.types.Author!.root) + + // Secret is removed; Author becomes public again. + const v3 = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(v2.version), + types: [type('Author', authorSchema, null)], + metadata: null, + }) + if (v3.status !== 'committed') throw new Error(v3.status) + const root3 = await repo.root(v3.version.hash) + expect(root3.private).toBe(null) + expect(root3.public.types.Author!.root).toBe(root1.public.types.Author!.root) + expect(v3.version.changes).toMatchObject({ removed: 1 }) + }) + + it('refuses a version holding one id in both sets', async () => { + const h = await harness() + const c = await h.collection() + const v1 = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [type('Author', authorSchema, [up('Author', 'a', { name: 'A' })])], + metadata: null, + }) + if (v1.status !== 'committed') throw new Error(v1.status) + // 'a' added to the private set without leaving the public one. + const both = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(v1.version), + types: [type('Author', authorSchema, null, [up('Author', 'a', { name: 'secret' })])], + metadata: null, + }).catch((e: Error) => e) + expect(both).toBeInstanceOf(Error) + expect((both as Error).message).toMatch(/in both the public and private sets/) + // Moving it (removed from one set, added to the other) is fine. + const moved = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(v1.version), + types: [type('Author', authorSchema, [del('a')], [up('Author', 'a', { name: 'secret' })])], + metadata: null, + }) + expect(moved.status).toBe('committed') + }) + + it('moves file references with a type: public → private → delete', async () => { + const h = await harness() + const c = await h.collection() + const repo = await h.ports.stores.forCollection(c.id) + await h.ports.db + .insert(schema.files) + .values({ hash: FILE, size: 1234, mimeType: 'image/png', storageKey: `files/${FILE}` }) + await h.ports.db.insert(schema.fileUploads).values({ + collectionId: c.id, + hash: FILE, + size: 1234, + mimeType: 'image/png', + storageKey: `files/${FILE}`, + status: 'verified', + }) + const v1 = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [ + type('Author', authorSchema, [ + up('Author', 'a', { name: 'A', photo: { $file: `sha256:${FILE}` } }), + up('Author', 'b', { name: 'B' }), + ]), + ], + metadata: null, + }) + if (v1.status !== 'committed') throw new Error(v1.status) + expect((await repo.root(v1.version.hash)).public.files.count).toBe(1) + + // The type becomes private: a plain tree move, and its file goes with it. + const privAuthor = { ...authorSchema, private: true } + const v2 = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(v1.version), + types: [type('Author', privAuthor, null)], + metadata: null, + }) + if (v2.status !== 'committed') throw new Error(v2.status) + const root2 = await repo.root(v2.version.hash) + const priv2 = await repo.privateSet(root2.private!) + expect(priv2.types.Author!.schema).toBe(hashSchema(privAuthor)) + expect(root2.public.files).toMatchObject({ root: null, count: 0 }) + expect(priv2.files).toMatchObject({ count: 1, bytes: 1234 }) + + // Deleting the record that references it releases the file. + const v3 = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(v2.version), + types: [type('Author', privAuthor, null, [del('a')])], + metadata: null, + }) + if (v3.status !== 'committed') throw new Error(v3.status) + const priv3 = await repo.privateSet((await repo.root(v3.version.hash)).private!) + expect(priv3.files).toMatchObject({ root: null, count: 0 }) + expect(priv3.types.Author!.count).toBe(1) + }) + + it('moves and removes a type by its per-type file counts, reading no records', async () => { + const h = await harness() + const c = await h.collection() + const repo = await h.ports.stores.forCollection(c.id) + await h.ports.db + .insert(schema.files) + .values({ hash: FILE, size: 1234, mimeType: 'image/png', storageKey: `files/${FILE}` }) + await h.ports.db.insert(schema.fileUploads).values({ + collectionId: c.id, + hash: FILE, + size: 1234, + mimeType: 'image/png', + storageKey: `files/${FILE}`, + status: 'verified', + }) + // Enough records for several leaves; three reference the file. + const authors = Array.from({ length: 3000 }, (_, i) => + up( + 'Author', + `a${i}`, + i % 1000 === 0 ? { name: `A${i}`, photo: { $file: `sha256:${FILE}` } } : { name: `A${i}` }, + ), + ) + const commit = async (base: BaseVersion | null, types: TypeInput[]) => { + const r = await commitVersion(h.ports, { collectionId: c.id, base, types, metadata: null }) + if (r.status !== 'committed') throw new Error(r.status) + return r.version + } + const v1 = await commit(null, [ + type('Author', authorSchema, authors), + type('Note', authorSchema, [up('Note', 'n', { name: 'n' })]), + ]) + const reads = vi.spyOn(Repo.prototype, 'bodyLines') + reads.mockClear() + + // Public → private, unchanged: the tree and its three references move as they are. + const privAuthor = { ...authorSchema, private: true } + const v2 = await commit(baseOf(v1), [ + type('Author', privAuthor, null), + type('Note', authorSchema, null), + ]) + expect(reads).not.toHaveBeenCalled() + const root1 = await repo.root(v1.hash) + const root2 = await repo.root(v2.hash) + const priv2 = await repo.privateSet(root2.private!) + expect(priv2.types.Author!.root).toBe(root1.public.types.Author!.root) + expect(priv2.files.count).toBe(1) + expect(root2.public.files.count).toBe(0) + + // Private → public, unchanged: the same tree back, the same as a rebuild makes. + const v3 = await commit(baseOf(v2), [ + type('Author', authorSchema, null), + type('Note', authorSchema, null), + ]) + expect(reads).not.toHaveBeenCalled() + const root3 = await repo.root(v3.hash) + expect(root3.public.types.Author!.root).toBe(root1.public.types.Author!.root) + expect(root3.public.files.count).toBe(1) + expect(root3.private).toBeNull() + // Every record counts as added in the public set, as before. + expect(v3.changes).toMatchObject({ added: 3000 }) + + // Removing the type releases its references without reading it. + const v4 = await commit(baseOf(v3), [type('Note', authorSchema, null)]) + expect(reads).not.toHaveBeenCalled() + const root4 = await repo.root(v4.hash) + expect(root4.public.files.count).toBe(0) + expect(v4.changes).toMatchObject({ removed: 3000 }) + expect(v4.publicRefsRoot).toBeNull() + reads.mockRestore() + }) + + it('falls back to reading records on a count tree without per-type counts', async () => { + const h = await harness() + const c = await h.collection() + const repo = await h.ports.stores.forCollection(c.id) + await h.ports.db + .insert(schema.files) + .values({ hash: FILE, size: 1234, mimeType: 'image/png', storageKey: `files/${FILE}` }) + await h.ports.db.insert(schema.fileUploads).values({ + collectionId: c.id, + hash: FILE, + size: 1234, + mimeType: 'image/png', + storageKey: `files/${FILE}`, + status: 'verified', + }) + const v1 = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [ + type('Author', authorSchema, [ + up('Author', 'a', { name: 'A', photo: { $file: `sha256:${FILE}` } }), + ]), + ], + metadata: null, + }) + if (v1.status !== 'committed') throw new Error(v1.status) + // The count tree an older writer made: set counts only, no per-type keys or marker. + const legacy = await applyFileSet( + repo, + { refsRoot: null, files: { root: null, count: 0, bytes: 0 } }, + new Map([[FILE, 1]]), + null, + async (hs) => new Map(hs.map((x) => [x, 1234])), + ) + expect(await tracksTypes(repo, legacy.refsRoot)).toBe(false) + const reads = vi.spyOn(Repo.prototype, 'bodyLines') + reads.mockClear() + const v2 = await commitVersion(h.ports, { + collectionId: c.id, + base: { ...baseOf(v1.version), publicRefsRoot: legacy.refsRoot }, + types: [type('Author', { ...authorSchema, private: true }, null)], + metadata: null, + }) + if (v2.status !== 'committed') throw new Error(v2.status) + expect(reads).toHaveBeenCalled() + reads.mockRestore() + const root2 = await repo.root(v2.version.hash) + expect((await repo.privateSet(root2.private!)).files.count).toBe(1) + // The private tree was empty, so it starts with per-type counts; the public one stays legacy. + expect(await tracksTypes(repo, v2.version.privateRefsRoot)).toBe(true) + }) + + it('keeps file sets by reference', async () => { + const h = await harness() + const c = await h.collection() + const repo = await h.ports.stores.forCollection(c.id) + const withPhoto = (id: string) => + up('Author', id, { name: id, photo: { $file: `sha256:${FILE}` } }) + const attempt = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [type('Author', authorSchema, [withPhoto('a')])], + metadata: null, + }).catch((e) => e) + expect(attempt).toBeInstanceOf(MissingFilesError) + + // A file row alone isn't enough: the bytes must have been uploaded under this collection. + await h.ports.db + .insert(schema.files) + .values({ hash: FILE, size: 1234, mimeType: 'image/png', storageKey: `files/${FILE}` }) + expect( + await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [type('Author', authorSchema, [withPhoto('a')])], + metadata: null, + }).catch((e) => e), + ).toBeInstanceOf(MissingFilesError) + await h.ports.db.insert(schema.fileUploads).values({ + collectionId: c.id, + hash: FILE, + size: 1234, + mimeType: 'image/png', + storageKey: `files/${FILE}`, + status: 'verified', + }) + const v1 = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [type('Author', authorSchema, [withPhoto('a'), withPhoto('b')], [withPhoto('p')])], + metadata: null, + }) + if (v1.status !== 'committed') throw new Error(v1.status) + const root1 = await repo.root(v1.version.hash) + expect(root1.public.files).toMatchObject({ count: 1, bytes: 1234 }) + expect((await repo.privateSet(root1.private!)).files.count).toBe(1) + + // Dropping one public reference keeps the file; dropping both removes it + // from the public set, but the cumulative public files tree keeps it. + const v2 = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(v1.version), + types: [type('Author', authorSchema, [del('a')], null)], + metadata: null, + }) + if (v2.status !== 'committed') throw new Error(v2.status) + expect((await repo.root(v2.version.hash)).public.files.count).toBe(1) + const v3 = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(v2.version), + types: [type('Author', authorSchema, [del('b')], null)], + metadata: null, + }) + if (v3.status !== 'committed') throw new Error(v3.status) + expect((await repo.root(v3.version.hash)).public.files.count).toBe(0) + const [col] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, c.id)) + const cumulative = await collect(iterate(new RepoSource(fileTree, repo), col!.publicFilesRoot)) + expect(cumulative.map((f) => f.key)).toEqual([FILE]) + }) + + it('revalidates records when a schema changes', async () => { + const h = await harness() + const c = await h.collection() + const v1 = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [ + type('Author', authorSchema, [ + up('Author', 'a', { name: 'A' }), + up('Author', 'b', { name: 7 }), + ]), + ], + metadata: null, + }) + if (v1.status !== 'committed') throw new Error(v1.status) + const strict = { ...authorSchema, required: ['name'], additionalProperties: false } + const validate = (_s: Record, data: unknown) => + typeof (data as { name?: unknown }).name === 'string' ? null : ['/name must be string'] + const r = await commitVersion(h.ports, { + collectionId: c.id, + base: baseOf(v1.version), + types: [type('Author', strict, null)], + metadata: null, + validate, + }) + expect(r).toMatchObject({ + status: 'invalid', + total: 1, + errors: [{ recordId: 'b', type: 'Author' }], + }) + }) +}) + +describe('publishVersion', () => { + const row = (collectionId: string, id: string) => ({ + id, + collectionId, + seq: 1, + semver: 'v1.0.0', + major: 1, + minor: 0, + patch: 0, + hash: `ulv2:${'0'.repeat(64)}`, + baseSemver: null, + message: null, + pushedBy: null, + appId: null, + actorId: null, + recordCount: 0, + publicRecordCount: 0, + fileCount: 0, + totalBytes: 0, + publicFileCount: 0, + publicTotalBytes: 0, + typeCounts: {}, + publicTypeCounts: {}, + hasPrivate: false, + publicRefsRoot: null, + privateRefsRoot: null, + changes: { added: 0, removed: 0, updated: 0 }, + }) + + it('records a fork in the publish batch, and not when the publish loses', async () => { + const h = await harness() + const parent = await h.collection('parent') + const child = await h.collection('child') + const fork = { parentCollectionId: parent.id, parentSeq: 1, sets: 'public' as const } + const input = (id: string, baseVersionId: string | null) => ({ + fence: 0, + version: row(child.id, id), + baseVersionId, + collectionUpdate: { publicFilesRoot: null, summary: null }, + schemaHashes: [], + usage: [], + fork, + }) + // The head isn't 'nope', so this publish loses: no version, no fork row. + expect((await publishVersion(h.ports.db, input(crypto.randomUUID(), 'nope'))).ok).toBe(false) + expect(await h.ports.db.select().from(schema.forks)).toEqual([]) + expect((await publishVersion(h.ports.db, input(crypto.randomUUID(), null))).ok).toBe(true) + expect(await h.ports.db.select().from(schema.forks)).toMatchObject([ + { childCollectionId: child.id, parentCollectionId: parent.id, parentSeq: 1, sets: 'public' }, + ]) + }) +}) diff --git a/packages/server/test/d1-limits.test.ts b/packages/server/test/d1-limits.test.ts new file mode 100644 index 0000000..2511c3c --- /dev/null +++ b/packages/server/test/d1-limits.test.ts @@ -0,0 +1,180 @@ +/** + * D1 binds at most 100 parameters per statement and runs at most 1,000 queries + * per invocation. The harness refuses statements over 100 parameters + * (db/node.ts), so these exercise the routes with lists past that. + */ +import { createHash } from 'node:crypto' + +import { hashRecord } from '@underlay/protocol' +import { afterAll, describe, expect, it } from 'vitest' + +import { MAX_BATCH_HASHES } from '../src/api/records.js' +import * as schema from '../src/db/schema.js' +import { cleanup, type Harness, harness } from './harness.js' + +afterAll(cleanup) + +const sha = (b: string) => createHash('sha256').update(b).digest('hex') +const Doc = { type: 'object', properties: { pdf: {} } } +const Author = { type: 'object', properties: { name: { type: 'string' } } } + +async function json(res: Response) { + return (await res.json()) as any +} + +async function push(h: Harness, user: string, base: string, body: object, records: object[]) { + const sid = (await json(await h.request(`${base}/push`, { method: 'POST', user, json: body }))) + .session_id + if (records.length) + await h.request(`${base}/push/${sid}/records`, { method: 'POST', user, ndjson: records }) + const res = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(res.status).toBe(201) + await h.drain() +} + +describe('D1 limits', () => { + it('the harness refuses a statement D1 would refuse', async () => { + const h = await harness() + const ids = Array.from({ length: 101 }, (_, i) => String(i)) + const { inArray } = await import('drizzle-orm') + await expect( + h.ports.db.select().from(schema.files).where(inArray(schema.files.hash, ids)), + ).rejects.toSatisfy((e) => /binds 101 parameters/.test(String((e as Error).cause))) + }) + + it('lists more than 100 files of a version', async () => { + const h = await harness() + const user = await h.member() + await h.collection('docs') + const base = '/api/collections/org/docs' + const bodies = Array.from({ length: 120 }, (_, i) => `file ${i}`) + for (const b of bodies) { + expect( + (await h.request(`${base}/files/${sha(b)}`, { method: 'PUT', user, body: b })).status, + ).toBe(201) + } + await push( + h, + user, + base, + { schemas: { Doc } }, + bodies.map((b, i) => ({ + id: `d${i}`, + type: 'Doc', + data: { pdf: { $file: `sha256:${sha(b)}` } }, + })), + ) + const res = await h.request(`${base}/versions/latest/files`, { user }) + expect(res.status).toBe(200) + const files = await json(res) + expect(files.length).toBe(120) + expect(files.every((f: any) => f.size !== null)).toBe(true) + + // Presigning many files reads the head and the file rows once, not per hash. + const before = h.statements() + const presign = await h.request(`${base}/files/presign`, { + method: 'POST', + user, + json: { hashes: [...bodies.map((b) => `sha256:${sha(b)}`), sha('not uploaded')] }, + }) + expect(presign.status).toBe(200) + const urls = await json(presign) + expect(Object.values(urls).filter(Boolean).length).toBe(120) + expect(urls[sha('not uploaded')]).toBe(null) + expect(h.statements() - before).toBeLessThan(20) + }) + + it('serves a user in more than 100 orgs, and an org with more than 100 members', async () => { + const h = await harness() + const user = await h.member() + await h.collection('c') + await push(h, user, '/api/collections/org/c', { schemas: { Author } }, [ + { id: 'a', type: 'Author', data: { name: 'A' } }, + ]) + for (let i = 0; i < 120; i += 25) { + await h.ports.db + .insert(schema.organization) + .values( + Array.from({ length: 25 }, (_, j) => ({ id: `o${i + j}`, name: 'O', slug: `o${i + j}` })), + ) + } + for (let i = 0; i < 120; i += 25) { + await h.ports.db.insert(schema.member).values( + Array.from({ length: 25 }, (_, j) => ({ + organizationId: `o${i + j}`, + userId: user, + role: 'member' as const, + })), + ) + } + const mine = await h.request('/api/collections?mine=true', { user }) + expect(mine.status).toBe(200) + expect((await json(mine)).collections.map((x: any) => x.slug)).toEqual(['c']) + expect((await h.request('/api/schemas', { user })).status).toBe(200) + + for (let i = 0; i < 120; i += 10) { + const ids = Array.from({ length: 10 }, (_, j) => `m${i + j}`) + await h.ports.db + .insert(schema.user) + .values(ids.map((id) => ({ id, name: id, email: `${id}@example.org` }))) + await h.ports.db + .insert(schema.member) + .values(ids.map((id) => ({ organizationId: 'org1', userId: id, role: 'member' as const }))) + } + const members = await h.request('/api/accounts/org/members', { user }) + expect(members.status).toBe(200) + expect((await json(members)).length).toBe(121) + }) + + it('removes an org default mirror from an org with more than 100 collections', async () => { + const h = await harness() + const user = await h.member() + for (let i = 0; i < 101; i++) await h.collection(`c${i}`) + const [loc] = await h.ports.db + .insert(schema.storageLocations) + .values({ + organizationId: 'org1', + kind: 's3', + name: 'L', + status: 'active', + }) + .returning() + const [def] = await h.ports.db + .insert(schema.placements) + .values({ organizationId: 'org1', locationId: loc!.id, role: 'mirror', sets: 'public' }) + .returning() + const res = await h.request(`/api/orgs/org/placements/${def!.id}`, { method: 'DELETE', user }) + expect(res.status).toBe(204) + }) + + it('answers provenance across many forks in a bounded number of queries', async () => { + const h = await harness() + const user = await h.member() + await h.collection('lib') + await h.ports.db.update(schema.collections).set({ public: true }) + await push(h, user, '/api/collections/org/lib', { schemas: { Author } }, [ + { id: 'a', type: 'Author', data: { name: 'A' } }, + ]) + for (let i = 0; i < 30; i++) { + const res = await h.request('/api/collections/org/lib/fork', { + method: 'POST', + user, + json: { targetOrg: 'org', slug: `fork-${i}` }, + }) + expect(res.status).toBe(201) + } + await h.drain() + const hash = hashRecord('a', 'Author', { name: 'A' }).hash + const before = h.statements() + const res = await h.request(`/api/records/${hash}/provenance`, { user }) + expect(res.status).toBe(200) + expect((await json(res)).references.length).toBe(31) + expect(h.statements() - before).toBeLessThan(20) + + const batch = await h.request('/api/records/batch', { + method: 'POST', + json: { hashes: Array.from({ length: MAX_BATCH_HASHES + 1 }, () => hash) }, + }) + expect(batch.status).toBe(400) + }) +}) diff --git a/packages/server/test/db.test.ts b/packages/server/test/db.test.ts new file mode 100644 index 0000000..9a45ee7 --- /dev/null +++ b/packages/server/test/db.test.ts @@ -0,0 +1,149 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { memoryStore } from '@underlay/protocol' +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import { MemoryCache } from '../src/cache.js' +import { openNodeDb } from '../src/db/node.js' +import * as schema from '../src/db/schema.js' +import { drainSqliteJobs, registerJob, SqliteJobs } from '../src/jobs.js' +import type { Ports } from '../src/ports.js' +import { createStores } from '../src/stores.js' + +const dirs: string[] = [] +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +async function tempDb() { + const dir = await mkdtemp(join(tmpdir(), 'ul-db-')) + dirs.push(dir) + return openNodeDb(`file:${join(dir, 'test.sqlite')}`) +} + +describe('SQLite on Node', () => { + it('migrates and round-trips rows, including JSON and timestamps', async () => { + const db = await tempDb() + await db.insert(schema.organization).values({ id: 'org1', name: 'Org', slug: 'org' }) + const [c] = await db + .insert(schema.collections) + .values({ + organizationId: 'org1', + slug: 'c', + name: 'C', + privateSalt: 'ab'.repeat(32), + summary: { tags: ['x'] }, + }) + .returning() + expect(c!.summary).toEqual({ tags: ['x'] }) + expect(c!.createdAt).toBeInstanceOf(Date) + expect(Math.abs(c!.createdAt.getTime() - Date.now())).toBeLessThan(5000) + }) + + it('runs atomic batches, and rolls back the whole batch on failure', async () => { + const db = await tempDb() + await db.insert(schema.organization).values({ id: 'org1', name: 'Org', slug: 'org' }) + await expect( + db.batch([ + db.insert(schema.organization).values({ id: 'org2', name: 'Two', slug: 'two' }), + db.insert(schema.organization).values({ id: 'org3', name: 'Dup', slug: 'org' }), // unique violation + ]), + ).rejects.toThrow() + expect( + await db.select().from(schema.organization).where(eq(schema.organization.id, 'org2')), + ).toEqual([]) + }) +}) + +describe('SQLite jobs', () => { + it('runs jobs, retries failures with backoff, and claims each job once', async () => { + const db = await tempDb() + const ports: Ports = { + db, + stores: createStores(db, new MemoryCache(), { + bucket: memoryStore(), + repoPrefix: 'repo', + internalPrefix: 'internal', + }), + cache: new MemoryCache(), + outboundFetch: fetch, + signer: async () => { + throw new Error('not used') + }, + jobs: new SqliteJobs(db), + waitUntil: () => {}, + } + const seen: string[] = [] + let failOnce = true + registerJob('test.echo', async (job) => { + seen.push(String(job.value)) + }) + registerJob('test.flaky', async () => { + if (failOnce) { + failOnce = false + throw new Error('boom') + } + seen.push('flaky ok') + }) + await ports.jobs.enqueueBatch([ + { type: 'test.echo', value: 'a' }, + { type: 'test.echo', value: 'b' }, + ]) + await ports.jobs.enqueue({ type: 'test.flaky' }) + expect(await drainSqliteJobs(ports)).toBe(3) + expect(seen.sort()).toEqual(['a', 'b']) + // The flaky job is back in the queue with a future runAt. + const [retry] = await db.select().from(schema.jobs) + expect(retry!.status).toBe('queued') + expect(retry!.attempts).toBe(1) + await db.update(schema.jobs).set({ runAt: new Date(0) }) + expect(await drainSqliteJobs(ports)).toBe(1) + expect(seen).toContain('flaky ok') + expect(await db.select().from(schema.jobs)).toEqual([]) + }) +}) + +describe('placements', () => { + it('resolves a collection to its primary location, and allows only one primary', async () => { + const db = await tempDb() + const bucket = memoryStore() + const stores = createStores(db, new MemoryCache(), { + bucket, + repoPrefix: 'repo', + internalPrefix: 'internal', + }) + await db.insert(schema.organization).values({ id: 'org1', name: 'Org', slug: 'org' }) + const [c] = await db + .insert(schema.collections) + .values({ organizationId: 'org1', slug: 'c', name: 'C', privateSalt: 'ab'.repeat(32) }) + .returning() + await expect(stores.forCollection(c!.id)).rejects.toThrow(/no primary/) + await db.insert(schema.placements).values({ + collectionId: c!.id, + locationId: schema.PLATFORM_LOCATION_ID, + role: 'primary', + sets: 'public+private', + }) + const repo = await stores.forCollection(c!.id) + await repo.putSchema({ type: 'object' }) + await stores.internal.put('sessions/s1/x', 'y') + expect([...bucket.objects.keys()].sort()).toEqual([ + 'internal/sessions/s1/x', + expect.stringMatching(/^repo\/schemas\/[0-9a-f]{64}\.json$/), + ]) + await db + .insert(schema.storageLocations) + .values({ id: 'other', kind: 'platform', name: 'Other' }) + await expect( + db + .insert(schema.placements) + .values({ collectionId: c!.id, locationId: 'other', role: 'primary', sets: 'public' }), + ).rejects.toThrow() + await db + .insert(schema.placements) + .values({ collectionId: c!.id, locationId: 'other', role: 'mirror', sets: 'public' }) + }) +}) diff --git a/packages/server/test/export.test.ts b/packages/server/test/export.test.ts new file mode 100644 index 0000000..0817d6b --- /dev/null +++ b/packages/server/test/export.test.ts @@ -0,0 +1,133 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterAll, describe, expect, it, vi } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const LONG = 'T'.repeat(120) + +async function json(res: Response) { + return (await res.json()) as any +} + +describe('export', () => { + it('streams a valid tar.gz of what the caller may read', async () => { + const h = await harness() + const user = await h.member() + await h.collection('lib') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/lib' + const file = 'file bytes' + const fileHash = createHash('sha256').update(file).digest('hex') + await h.request(`${base}/files/${fileHash}`, { method: 'PUT', user, body: file }) + const Doc = { type: 'object' } + const sid = ( + await json( + await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Doc, [LONG]: Doc }, metadata: { readme: '# Lib\n' } }, + }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [ + { id: 'd1', type: 'Doc', data: { title: 'Ünïcode ✓', f: { $file: `sha256:${fileHash}` } } }, + { id: 'd2', type: 'Doc', data: { title: 'hidden' }, private: true }, + { id: 'l1', type: LONG, data: { x: 1 } }, + ], + }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + + const res = await h.request(`${base}/export`) + expect(res.headers.get('content-disposition')).toContain('org-lib-v1.0.0.tar.gz') + const dir = await mkdtemp(join(tmpdir(), 'ul-export-')) + await writeFile(join(dir, 'a.tar.gz'), new Uint8Array(await res.arrayBuffer())) + execFileSync('tar', ['-xzf', 'a.tar.gz'], { cwd: dir }) + const manifest = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8')) + expect(manifest.version).toMatchObject({ semver: 'v1.0.0', recordCount: 2, fileCount: 1 }) + expect(await readFile(join(dir, 'README.md'), 'utf8')).toBe('# Lib\n') + const docs = (await readFile(join(dir, 'records/Doc.ndjson'), 'utf8')) + .trim() + .split('\n') + .map((l) => JSON.parse(l)) + expect(docs.map((d) => d.id)).toEqual(['d1']) // private record excluded for anonymous readers + expect(docs[0].hash).toMatch(/^[0-9a-f]{64}$/) + expect( + (await readFile(join(dir, `records/${LONG}.ndjson`), 'utf8')).trim().split('\n').length, + ).toBe(1) + expect(await readFile(join(dir, `files/${fileHash}`), 'utf8')).toBe(file) + + // Members get the private record too; plain tar works as well. + const owner = await h.request(`${base}/export?format=tar`, { user }) + await writeFile(join(dir, 'b.tar'), new Uint8Array(await owner.arrayBuffer())) + execFileSync('mkdir', ['-p', 'b'], { cwd: dir }) + execFileSync('tar', ['-xf', '../b.tar'], { cwd: join(dir, 'b') }) + const all = (await readFile(join(dir, 'b/records/Doc.ndjson'), 'utf8')).trim().split('\n') + expect(all.length).toBe(2) + + // An unknown format is refused, naming the ones there are. + const zip = await h.request(`${base}/export?format=zip`) + expect(zip.status).toBe(400) + expect(((await zip.json()) as { error: string }).error).toMatch(/tar or tar\.gz/) + expect((await h.request(`${base}/export?format=tar.gz`)).status).toBe(200) + }) + + it('exports a version to the same bytes every time', async () => { + const h = await harness() + const user = await h.member() + await h.collection('lib') + const base = '/api/collections/org/lib' + const sid = ( + await json( + await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Doc: { type: 'object' } }, metadata: { readme: '# Lib\n' } }, + }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [ + { id: 'd1', type: 'Doc', data: { n: 1 } }, + { id: 'd2', type: 'Doc', data: { n: 2 }, private: true }, + ], + }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + const [v] = await h.ports.db.select().from(schema.versions) + const bytes = async (format: string) => + new Uint8Array( + await ( + await h.request(`${base}/export?version=v1.0.0&format=${format}`, { user }) + ).arrayBuffer(), + ) + for (const format of ['tar', 'tar.gz']) { + const first = await bytes(format) + // A later export, an hour on, is the same archive. + const now = Date.now() + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 3_600_000) + const second = await bytes(format) + clock.mockRestore() + expect(second).toEqual(first) + } + // Entries carry the version's time (the tar header's mtime, octal seconds). + const tar = await bytes('tar') + const mtime = new TextDecoder().decode(tar.subarray(136, 147)) + expect(parseInt(mtime, 8)).toBe(Math.floor(v!.createdAt.getTime() / 1000)) + }) +}) diff --git a/packages/server/test/files.test.ts b/packages/server/test/files.test.ts new file mode 100644 index 0000000..22e7468 --- /dev/null +++ b/packages/server/test/files.test.ts @@ -0,0 +1,376 @@ +import { createHash } from 'node:crypto' + +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { inertBlobResponse } from '../src/files/files.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const sha = (b: string | Uint8Array) => createHash('sha256').update(b).digest('hex') +const Doc = { type: 'object', properties: { title: { type: 'string' }, pdf: {} } } + +async function json(res: Response) { + return (await res.json()) as any +} + +describe('files', () => { + it('uploads small files through the API, verifying the hash', async () => { + const h = await harness() + const user = await h.member() + await h.collection('docs') + const base = '/api/collections/org/docs' + const bytes = 'hello file' + let res = await h.request(`${base}/files/${'0'.repeat(64)}`, { + method: 'PUT', + user, + body: bytes, + }) + expect(res.status).toBe(400) + res = await h.request(`${base}/files/sha256:${sha(bytes)}`, { + method: 'PUT', + user, + body: bytes, + headers: { 'content-type': 'text/html' }, + }) + expect(res.status).toBe(201) + const [f] = await h.ports.db.select().from(schema.files) + // HTML is stored as inert bytes; the key is the canonical repository key. + expect(f).toMatchObject({ + hash: sha(bytes), + size: 10, + mimeType: 'application/octet-stream', + storageKey: `repo/files/${sha(bytes)}`, + }) + expect(h.bucket.objects.has(`repo/files/${sha(bytes)}`)).toBe(true) + }) + + it('presigns multipart parts a page at a time, within 10,000 parts and 5 TiB', async () => { + const h = await harness() + const user = await h.member() + await h.collection('docs') + const base = '/api/collections/org/docs' + const start = (size: number) => + h.request(`${base}/files/uploads`, { method: 'POST', user, json: { hash: sha('big'), size } }) + const GiB = 1024 ** 3 + const big = await json(await start(50 * GiB)) + expect(big).toMatchObject({ partBytes: 100 * 1024 ** 2, partCount: 512 }) + expect(big.parts).toHaveLength(100) + expect(big.parts[99].partNumber).toBe(100) + const next = await json( + await h.request(`${base}/files/uploads/${big.id}/parts?from=101`, { user }), + ) + expect(next.parts.map((p: { partNumber: number }) => p.partNumber)).toEqual( + Array.from({ length: 100 }, (_, i) => 101 + i), + ) + const tail = await json( + await h.request(`${base}/files/uploads/${big.id}/parts?from=501`, { user }), + ) + expect(tail.parts).toHaveLength(12) + // The largest file takes bigger parts, never more than 10,000 of them. + const most = await json(await start(5 * 1024 * GiB)) + expect(most.partCount).toBeLessThanOrEqual(10_000) + expect(most.partBytes * most.partCount).toBeGreaterThanOrEqual(5 * 1024 * GiB) + expect(most.parts).toHaveLength(100) + expect((await start(5 * 1024 * GiB + 1)).status).toBe(413) + }) + + it('verifies direct uploads in a job and copies them to the canonical key', async () => { + const h = await harness() + const user = await h.member() + await h.collection('docs') + const base = '/api/collections/org/docs' + const good = new TextEncoder().encode('x'.repeat(100_000)) + let res = await h.request(`${base}/files/uploads`, { + method: 'POST', + user, + json: { hash: sha(good), size: good.length, mimeType: 'application/pdf' }, + }) + expect(res.status).toBe(201) + const ticket = await json(res) + expect(ticket.url).toBeTruthy() + // The client PUTs to the presigned URL; in tests, write the staging key directly. + await h.bucket.put(`internal/uploads/${ticket.id}`, good) + res = await h.request(`${base}/files/uploads/${ticket.id}/complete`, { + method: 'POST', + user, + json: {}, + }) + expect(res.status).toBe(202) + await h.drain() + expect( + (await json(await h.request(`${base}/files/uploads/${ticket.id}`, { user }))).status, + ).toBe('verified') + expect(h.bucket.objects.has(`repo/files/${sha(good)}`)).toBe(true) + expect(h.bucket.objects.has(`internal/uploads/${ticket.id}`)).toBe(false) + + // Bytes that don't match the declared hash are refused and removed. + const bad = await json( + await h.request(`${base}/files/uploads`, { + method: 'POST', + user, + json: { hash: sha('other'), size: 3 }, + }), + ) + await h.bucket.put(`internal/uploads/${bad.id}`, 'abc') + await h.request(`${base}/files/uploads/${bad.id}/complete`, { method: 'POST', user, json: {} }) + await h.drain() + const status = await json(await h.request(`${base}/files/uploads/${bad.id}`, { user })) + expect(status).toMatchObject({ status: 'failed' }) + expect(status.error).toMatch(/Hash mismatch/) + expect( + ( + await h.ports.db + .select() + .from(schema.files) + .where(eq(schema.files.hash, sha('other'))) + ).length, + ).toBe(0) + }) + + it("doesn't let a commit reference another collection's file without uploading it", async () => { + const h = await harness() + const user = await h.member() + await h.collection('v') + await h.collection('a') + await h.ports.db.update(schema.collections).set({ public: true }) + const elsewhere = 'bytes only collection v has' + expect( + ( + await h.request(`/api/collections/org/v/files/${sha(elsewhere)}`, { + method: 'PUT', + user, + body: elsewhere, + }) + ).status, + ).toBe(201) + + const base = '/api/collections/org/a' + const attempt = async (hash: string) => { + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Doc } } }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [{ id: 'd', type: 'Doc', data: { pdf: { $file: `sha256:${hash}` } } }], + }) + const res = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + return { status: res.status, body: JSON.stringify(await json(res)).replaceAll(hash, 'H') } + } + const existing = await attempt(sha(elsewhere)) + const nowhere = await attempt(sha('bytes nobody has')) + // Same refusal either way: the answer says nothing about other collections. + expect(existing.status).toBe(422) + expect(existing).toEqual(nowhere) + expect((await h.request(`${base}/files/${sha(elsewhere)}`)).status).toBe(404) + + // Uploading the bytes under this collection is the proof that lets it commit. + expect( + (await h.request(`${base}/files/${sha(elsewhere)}`, { method: 'PUT', user, body: elsewhere })) + .status, + ).toBe(201) + expect((await attempt(sha(elsewhere))).status).toBe(201) + expect((await h.request(`${base}/files/${sha(elsewhere)}`)).status).toBe(302) + }) + + it('completes a multipart upload only with its parts', async () => { + const h = await harness() + const user = await h.member() + await h.collection('docs') + const base = '/api/collections/org/docs' + const ticket = await json( + await h.request(`${base}/files/uploads`, { + method: 'POST', + user, + json: { hash: sha('big'), size: 6 * 1024 ** 3 }, + }), + ) + expect(ticket.partCount).toBeGreaterThan(1) + const complete = (body: unknown) => + h.request(`${base}/files/uploads/${ticket.id}/complete`, { method: 'POST', user, json: body }) + for (const body of [{}, { parts: [] }, { parts: [{ partNumber: 'x', etag: 1 }] }]) { + const res = await complete(body) + expect(res.status).toBe(400) + expect((await json(res)).error).toBe('parts is required for a multipart upload') + } + expect( + (await json(await h.request(`${base}/files/uploads/${ticket.id}`, { user }))).status, + ).toBe('pending') + }) + + it('lets members read any file the collection holds, not only the head’s', async () => { + const h = await harness() + const user = await h.member() + await h.collection('docs') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/docs' + const old = 'private bytes of an earlier version' + const staged = 'uploaded, not yet in any version' + for (const b of [old, staged]) { + await h.request(`${base}/files/${sha(b)}`, { method: 'PUT', user, body: b }) + } + const push = async (ndjson: unknown[], deletes?: unknown[]) => { + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Doc } } }), + ) + ).session_id + if (ndjson.length) + await h.request(`${base}/push/${sid}/records`, { method: 'POST', user, ndjson }) + if (deletes) + await h.request(`${base}/push/${sid}/deletes`, { method: 'POST', user, ndjson: deletes }) + return (await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status + } + expect( + await push([ + { id: 'p', type: 'Doc', data: { pdf: { $file: `sha256:${sha(old)}` } }, private: true }, + { id: 'q', type: 'Doc', data: { title: 'stays' } }, + ]), + ).toBe(201) + // The next version drops the record: the file is no longer in the head. + expect(await push([], [{ type: 'Doc', id: 'p' }])).toBe(201) + + for (const b of [old, staged]) { + expect((await h.request(`${base}/files/${sha(b)}`, { user })).status).toBe(302) + expect((await h.request(`${base}/files/${sha(b)}`)).status).toBe(404) + } + const presign = async (u?: string) => + json( + await h.request(`${base}/files/presign`, { + method: 'POST', + ...(u ? { user: u } : {}), + json: { hashes: [sha(old), sha(staged)] }, + }), + ) + expect(Object.values(await presign(user)).every((u) => typeof u === 'string')).toBe(true) + expect(Object.values(await presign())).toEqual([null, null]) + }) + + it('serves filesystem blobs as inert attachments', async () => { + const html = new Response('', { + headers: { 'content-type': 'text/html', 'content-disposition': 'inline' }, + }) + const res = inertBlobResponse(html) + expect(res.headers.get('content-security-policy')).toBe('sandbox') + expect(res.headers.get('x-content-type-options')).toBe('nosniff') + expect(res.headers.get('content-disposition')).toBe('attachment') + const named = inertBlobResponse( + new Response('x', { headers: { 'content-disposition': 'attachment; filename="abc"' } }), + ) + expect(named.headers.get('content-disposition')).toBe('attachment; filename="abc"') + expect(await named.text()).toBe('x') + expect(inertBlobResponse(new Response('Forbidden', { status: 403 })).status).toBe(403) + }) + + it('serves files by redirect only to those who may read them', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('docs') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/docs' + const pub = 'public pdf bytes' + const priv = 'private pdf bytes' + for (const b of [pub, priv]) { + expect( + (await h.request(`${base}/files/${sha(b)}`, { method: 'PUT', user, body: b })).status, + ).toBe(201) + } + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Doc } } }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [ + { id: 'd1', type: 'Doc', data: { title: 'Public', pdf: { $file: `sha256:${sha(pub)}` } } }, + { + id: 'd2', + type: 'Doc', + data: { title: 'Private', pdf: { $file: `sha256:${sha(priv)}` } }, + private: true, + }, + { id: 'd3', type: 'Doc', data: { title: 'Again', pdf: { $file: `sha256:${sha(pub)}` } } }, + { + id: 'd4', + type: 'Doc', + data: { title: 'Hidden', pdf: { $file: `sha256:${sha(pub)}` } }, + private: true, + }, + ], + }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + + const anonPub = await h.request(`${base}/files/${sha(pub)}`) + expect(anonPub.status).toBe(302) + expect(anonPub.headers.get('location')).toContain(`repo/files/${sha(pub)}`) + expect((await h.request(`${base}/files/${sha(priv)}`)).status).toBe(404) + expect((await h.request(`${base}/files/${sha(priv)}`, { user })).status).toBe(302) + expect( + (await h.request(`${base}/files/${sha(pub)}`, { method: 'HEAD' })).headers.get( + 'content-length', + ), + ).toBe(String(pub.length)) + + // The files listing follows the same sets. + const anonList = await json(await h.request(`${base}/versions/latest/files`)) + expect(anonList.map((f: any) => f.hash)).toEqual([sha(pub)]) + const ownerList = await json(await h.request(`${base}/versions/latest/files`, { user })) + expect(ownerList.length).toBe(2) + // Reference counts follow the sets too: private references only for members. + const countsOf = (list: any[]) => + Object.fromEntries(list.map((f: any) => [f.hash, f.referenceCount])) + expect(countsOf(anonList)).toEqual({ [sha(pub)]: 2 }) + expect(countsOf(ownerList)).toEqual({ [sha(pub)]: 3, [sha(priv)]: 1 }) + void c + }) + + it('counts the file references of records stored out of line', async () => { + // Over 64 KB, a record's body is a pointer when written but the full JSON + // when read back, so its references must be counted through the pointer. + const h = await harness() + const user = await h.member() + await h.collection('docs') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/docs' + const pdf = 'large record pdf' + await h.request(`${base}/files/${sha(pdf)}`, { method: 'PUT', user, body: pdf }) + const big = (title: string) => ({ + id: 'big', + type: 'Doc', + data: { title: title + 'x'.repeat(70_000), pdf: { $file: `sha256:${sha(pdf)}` } }, + }) + const push = async (open: object, records: object[], deletes: object[] = []) => { + const sid = ( + await json(await h.request(`${base}/push`, { method: 'POST', user, json: open })) + ).session_id + if (records.length) + await h.request(`${base}/push/${sid}/records`, { method: 'POST', user, ndjson: records }) + if (deletes.length) + await h.request(`${base}/push/${sid}/deletes`, { method: 'POST', user, ndjson: deletes }) + const res = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(res.status).toBe(201) + return json(res) + } + const files = async () => + (await json(await h.request(`${base}/versions/latest/files`))).map((f: any) => f.hash) + + await push({ schemas: { Doc } }, [big('First')]) + expect(await files()).toEqual([sha(pdf)]) + // Changing the record moves the count -1 and +1: it used to go negative here. + await push({ base: 'v1.0.0' }, [big('Second')]) + expect(await files()).toEqual([sha(pdf)]) + // Deleting it takes the file out of the set. + const v = await push({ base: 'v1.1.0' }, [], [{ type: 'Doc', id: 'big' }]) + expect(v.semver).toBeTruthy() + expect(await files()).toEqual([]) + }) +}) diff --git a/packages/server/test/fsck.test.ts b/packages/server/test/fsck.test.ts new file mode 100644 index 0000000..7c8166f --- /dev/null +++ b/packages/server/test/fsck.test.ts @@ -0,0 +1,159 @@ +import { createHash } from 'node:crypto' + +import { + type FsckCursor, + fsck, + fsckStep, + keys, + type PublicKeyInfo, + type Repo, +} from '@underlay/protocol' +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const sha = (s: string) => createHash('sha256').update(s).digest('hex') +const Doc = { type: 'object', properties: { title: { type: 'string' }, pdf: { type: 'object' } } } + +describe('fsck', () => { + it('passes a healthy collection and names what breaks', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('docs') + const base = '/api/collections/org/docs' + const pdf = 'pdf bytes' + await h.request(`${base}/files/${sha(pdf)}`, { method: 'PUT', user, body: pdf }) + for (const [i, records] of [ + [ + 0, + Array.from({ length: 2500 }, (_, j) => ({ + id: `d${j}`, + type: 'Doc', + data: { title: `T${j}` }, + })), + ], + [ + 1, + [ + { id: 'f', type: 'Doc', data: { title: 'F', pdf: { $file: `sha256:${sha(pdf)}` } } }, + { id: 's', type: 'Doc', data: { title: 'S' }, private: true }, + ], + ], + ] as const) { + const open = await h.request(`${base}/push`, { + method: 'POST', + user, + json: i === 0 ? { schemas: { Doc } } : { actor_id: 'pubpub-user-7' }, + }) + const sid = ((await open.json()) as { session_id: string }).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: records as unknown[], + }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + } + const repo = await h.ports.stores.forCollection(c.id) + const trusted = [h.signer.publicKey] + // The actor a push names stays with the members: the public log carries null. + const entry = JSON.parse(await (await repo.blobs.get(`collections/${c.id}/log/2.json`))!.text()) + expect(entry.actorId).toBeNull() + const [row] = await h.ports.db.select().from(schema.versions).where(eq(schema.versions.seq, 2)) + expect(row!.actorId).toBe('pubpub-user-7') + const ok = await fsck(repo, { collectionId: c.id, trustedKeys: trusted, fileBytes: true }) + expect(ok).toMatchObject({ ok: true, errors: [], versions: 2, files: 1, log: 'trusted keys' }) + expect(ok.records).toBeGreaterThan(2500) + expect(ok.leaves).toBeGreaterThan(1) + // The resumable check agrees, a version per step. + const stepped = await steps(repo, c.id, trusted) + expect(stepped).toMatchObject({ ok: true, errors: [], versions: 2, files: 1, steps: 2 }) + expect(stepped.records).toBeGreaterThan(2500) + + // A head.json that names another version fails, though the log verifies. + const headKey = `collections/${c.id}/head.json` + const head = await (await repo.blobs.get(headKey))!.text() + const v1 = JSON.parse(await (await repo.blobs.get(`collections/${c.id}/log/1.json`))!.text()) + await repo.blobs.put( + headKey, + JSON.stringify({ ...JSON.parse(head), versionHash: v1.versionHash }), + ) + expect((await steps(repo, c.id, trusted)).errors).toEqual([ + 'head.json does not match the last log entry', + ]) + await repo.blobs.put(headKey, head) + + // Corrupt a file, drop a body, and sign nothing it trusts. + await repo.blobs.put(keys.file(sha(pdf)), 'other bytes!') + const bodies = [...h.bucket.objects.keys()].filter((k) => k.includes('/bodies/')) + h.bucket.objects.delete(bodies[0]!) + const bad = await fsck(repo, { collectionId: c.id, trustedKeys: [], fileBytes: true }) + expect(bad.ok).toBe(false) + const text = bad.errors.join('\n') + expect(text).toMatch(/log/) + expect(text).toMatch(/fails its hash or size/) + expect(text).toMatch(/body of|Missing body/) + const badSteps = await steps(repo, c.id, trusted) + expect(badSteps.ok).toBe(false) + expect(badSteps.errors.join('\n')).toMatch(/fails its hash or size/) + expect(badSteps.errors.join('\n')).toMatch(/body of|Missing body/) + }) +}) + +/** fsckStep to the end, one version a step, adding the steps' reports up. */ +async function steps(repo: Repo, collectionId: string, trustedKeys: PublicKeyInfo[]) { + let cursor: FsckCursor | null = null + const total = { ok: true, errors: [] as string[], versions: 0, records: 0, files: 0, steps: 0 } + do { + const r = await fsckStep(repo, { + collectionId, + trustedKeys, + fileBytes: true, + cursor, + changeBudget: 1, + }) + total.ok &&= r.report.ok + total.errors.push(...r.report.errors) + total.versions += r.report.versions + total.records += r.report.records + total.files += r.report.files + total.steps++ + cursor = r.cursor + } while (cursor) + return total +} + +describe('fsck for stewards', () => { + it('queues a check and serves its report', async () => { + const { setup } = await import('./kf-app.js') + const { h, call, user } = await setup() + await user('u1') // a steward in the fake KF Auth + const member = await h.member('u1') + await h.collection('c') + const open = await h.request('/api/collections/org/c/push', { + method: 'POST', + user: member, + json: { schemas: { Doc } }, + }) + const sid = ((await open.json()) as { session_id: string }).session_id + await h.request(`/api/collections/org/c/push/${sid}/records`, { + method: 'POST', + user: member, + ndjson: [{ id: 'a', type: 'Doc', data: { title: 'A' } }], + }) + await h.request(`/api/collections/org/c/push/${sid}/commit`, { method: 'POST', user: member }) + expect((await call('/api/admin/fsck?collection=org/c', { user: 'u1' })).status).toBe(404) + expect( + (await call('/api/admin/fsck', { method: 'POST', user: 'u1', json: { collection: 'org/c' } })) + .status, + ).toBe(202) + await h.drain() + const report = await (await call('/api/admin/fsck?collection=org/c', { user: 'u1' })).json() + expect(report).toMatchObject({ ok: true, versions: 1, log: 'trusted keys' }) + }) +}) diff --git a/packages/server/test/harness.ts b/packages/server/test/harness.ts new file mode 100644 index 0000000..9a97fdc --- /dev/null +++ b/packages/server/test/harness.ts @@ -0,0 +1,150 @@ +/** Ports over a temp SQLite file and an in-memory bucket, for integration tests. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + ed25519Signer, + generateSigningKey, + MemoryStore, + memoryStore, + newSalt, + type Signer, +} from '@underlay/protocol' + +import '../src/handlers.js' +import { createApp } from '../src/app.js' +import { MemoryCache } from '../src/cache.js' +import { D1_MAX_BOUND_PARAMS, openNodeDb } from '../src/db/node.js' +import * as schema from '../src/db/schema.js' +import { drainSqliteJobs, SqliteJobs } from '../src/jobs.js' +import type { Ports } from '../src/ports.js' +import { createStores } from '../src/stores.js' + +const dirs: string[] = [] + +/** Outbound requests made by the app (webhooks), and the responder tests set. */ +export const outboundCalls: { url: string; init: RequestInit }[] = [] +export let outboundResponder: (url: string) => Response = () => new Response('ok') +export function respondOutbound(f: (url: string) => Response) { + outboundResponder = f +} +const outbound = async (url: string, init: RequestInit) => { + outboundCalls.push({ url, init }) + return outboundResponder(url) +} + +export async function cleanup(): Promise { + for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) +} + +export interface Harness { + ports: Ports + /** The app, authenticating `x-test-user: ` as a signed-in user. */ + app: ReturnType + /** A user who is a member of the org that owns test collections. */ + member(id?: string): Promise + /** fetch against the app as a user (or anonymously). */ + request( + path: string, + init?: RequestInit & { user?: string; json?: unknown; ndjson?: unknown[] }, + ): Promise + bucket: MemoryStore + signer: Signer + /** Run queued jobs until none are ready. */ + drain(): Promise + /** SQL statements sent so far (D1 runs at most 1,000 per invocation). */ + statements(): number + /** An org and a collection with a primary placement on the platform location. */ + collection(slug?: string): Promise +} + +export async function harness(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'ul-it-')) + dirs.push(dir) + let statements = 0 + const db = await openNodeDb(`file:${join(dir, 'db.sqlite')}`, { + maxBoundParams: D1_MAX_BOUND_PARAMS, + onStatement: () => void statements++, + }) + const bucket = memoryStore() + const cache = new MemoryCache() + const signer = await ed25519Signer(await generateSigningKey()) + const ports: Ports = { + db, + stores: createStores(db, cache, { bucket, repoPrefix: 'repo', internalPrefix: 'internal' }), + cache, + signer: async () => signer, + jobs: new SqliteJobs(db), + waitUntil: (p) => void p.catch((err) => console.error(err)), + outboundFetch: (url, init) => outbound(url, init), + locationFetch: (req) => fetch(req), + locationKey: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'), + } + let orgMade = false + const ensureOrg = async () => { + if (orgMade) return + await db.insert(schema.organization).values({ id: 'org1', name: 'Org', slug: 'org' }) + orgMade = true + } + const app = createApp(() => ({ + ports, + config: { appUrl: 'http://test', deployment: 'test' }, + authenticate: async (req) => { + const user = req.headers.get('x-test-user') + return user ? { userId: user, scope: 'session', collectionIds: null } : null + }, + })) + return { + ports, + app, + bucket, + signer, + drain: () => drainSqliteJobs(ports), + statements: () => statements, + async member(id = 'u1') { + await ensureOrg() + await db + .insert(schema.user) + .values({ id, name: id, email: `${id}@example.org` }) + .onConflictDoNothing() + await db.insert(schema.member).values({ organizationId: 'org1', userId: id, role: 'owner' }) + return id + }, + async request(path, init = {}) { + const { user, json, ndjson, ...rest } = init + const headers = new Headers(rest.headers) + if (user) headers.set('x-test-user', user) + let body = rest.body + if (json !== undefined) { + body = JSON.stringify(json) + headers.set('content-type', 'application/json') + } + if (ndjson !== undefined) { + body = ndjson.map((l) => JSON.stringify(l)).join('\n') + headers.set('content-type', 'application/x-ndjson') + } + return app.fetch( + new Request(`http://test${path}`, { + ...rest, + headers, + ...(body !== undefined ? { body } : {}), + }), + ) + }, + async collection(slug = 'c') { + await ensureOrg() + const [c] = await db + .insert(schema.collections) + .values({ organizationId: 'org1', slug, name: slug, privateSalt: newSalt() }) + .returning() + await db.insert(schema.placements).values({ + collectionId: c!.id, + locationId: schema.PLATFORM_LOCATION_ID, + role: 'primary', + sets: 'public+private', + }) + return c! + }, + } +} diff --git a/packages/server/test/jobs.test.ts b/packages/server/test/jobs.test.ts new file mode 100644 index 0000000..1daaf18 --- /dev/null +++ b/packages/server/test/jobs.test.ts @@ -0,0 +1,64 @@ +import { afterAll, describe, expect, it } from 'vitest' + +import { drainSqliteJobs, QueueJobs, registerJob } from '../src/jobs.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const fakeQueue = () => { + const sent: string[] = [] + return { + sent, + async send(body: unknown) { + sent.push((body as { type: string }).type) + }, + async sendBatch(msgs: { body: unknown }[]) { + for (const m of msgs) sent.push((m.body as { type: string }).type) + }, + } +} + +describe('job queues', () => { + it('sends bulk jobs to the bulk queue and the rest to the interactive one', async () => { + const interactive = fakeQueue() + const bulk = fakeQueue() + const jobs = new QueueJobs(interactive, bulk) + await jobs.enqueue({ type: 'webhooks.deliver', deliveryId: 'd' }) + await jobs.enqueue({ type: 'commit.unit', unitId: 'u' }) + await jobs.enqueueBatch([ + { type: 'mirror.version', placementId: 'p' }, + { type: 'version.published', versionId: 'v' }, + ...Array.from({ length: 150 }, () => ({ type: 'commit.unit', unitId: 'u' })), + ]) + expect(interactive.sent).toEqual(['webhooks.deliver', 'version.published']) + // Hashing a whole file, indexing and the sweep are bulk work too. + await jobs.enqueueBatch([ + { type: 'files.verify', uploadId: 'f' }, + { type: 'refs.index', versionId: 'v' }, + { type: 'maintenance.sweep' }, + ]) + expect(interactive.sent).toHaveLength(2) + expect(bulk.sent).toHaveLength(155) + expect(bulk.sent.slice(0, 2)).toEqual(['commit.unit', 'mirror.version']) + + // With one queue bound, everything goes there once. + const one = fakeQueue() + await new QueueJobs(one).enqueueBatch([ + { type: 'commit.unit', unitId: 'u' }, + { type: 'files.verify', uploadId: 'f' }, + ]) + expect(one.sent).toEqual(['commit.unit', 'files.verify']) + }) + + it('runs interactive jobs before bulk ones on Node', async () => { + const h = await harness() + const ran: string[] = [] + registerJob('test.small', async () => void ran.push('small')) + // A real bulk type, its handler replaced (each test file has its own registry). + registerJob('repo.repairLog', async () => void ran.push('bulk')) + await h.ports.jobs.enqueue({ type: 'repo.repairLog', collectionId: 'x' }) + await h.ports.jobs.enqueue({ type: 'test.small' }) + await drainSqliteJobs(h.ports) + expect(ran).toEqual(['small', 'bulk']) + }) +}) diff --git a/packages/server/test/kf-app.ts b/packages/server/test/kf-app.ts new file mode 100644 index 0000000..b0d7e8a --- /dev/null +++ b/packages/server/test/kf-app.ts @@ -0,0 +1,91 @@ +/** + * An app over the test harness's ports that knows sessions, API keys and KF + * Auth (a fake), for routes that depend on who the caller is. + */ +import type { Principal } from '../src/api/access.js' +import { createApp } from '../src/app.js' +import type { Kf } from '../src/auth/kf.js' +import * as schema from '../src/db/schema.js' +import { harness } from './harness.js' + +/** A KF Auth stand-in: user u1 belongs to KF org kf-1 and is a steward. */ +export const fakeKf: Kf = { + profile: async (userId) => + userId === 'u1' ? { name: 'Ada KF', image: null, role: 'admin' } : null, + role: async (userId) => (userId === 'u1' ? 'admin' : null), + orgs: async (userId) => + userId === 'u1' + ? [{ id: 'kf-1', name: 'KF One', slug: 'kf-one', type: 'shared', role: 'owner' }] + : [], + entitled: async (userId, id) => userId === 'u1' && id === 'kf-1', + defaultOrgId: async () => null, + isInternalCall: (h) => h === 'Bearer internal-key', +} + +/** + * The harness's ports behind an app that knows sessions and keys: + * `x-test-user` signs in (session `s-`), `x-test-key: read|write|admin|scoped|org` + * (or `scoped-write:`, `scoped-admin:`) makes the caller an API key of that kind. + */ +export async function setup() { + const h = await harness() + const app = createApp(() => ({ + ports: h.ports, + config: { appUrl: 'http://test', deployment: 'test' }, + kf: fakeKf, + authenticate: async (req): Promise => { + const user = req.headers.get('x-test-user') + if (!user) return null + const key = req.headers.get('x-test-key') + if (key === 'read') return { userId: user, scope: 'read', collectionIds: null } + if (key === 'write' || key === 'admin') + return { userId: user, scope: key, collectionIds: null } + if (key === 'scoped') return { userId: user, scope: 'write', collectionIds: ['x'] } + // `scoped-write:`, `scoped-admin:`: confined to it. + const confined = key && /^scoped-(write|admin):(.+)$/.exec(key) + if (confined) + return { + userId: user, + scope: confined[1] as 'write' | 'admin', + collectionIds: [confined[2]!], + } + if (key === 'org') + return { userId: 'org1', scope: 'write', collectionIds: null, orgId: 'org1' } + return { userId: user, scope: 'session', collectionIds: null, sessionId: `s-${user}` } + }, + })) + const call = ( + path: string, + init: { + method?: string + user?: string + key?: string + json?: unknown + authorization?: string + } = {}, + ) => { + const headers = new Headers() + if (init.user) headers.set('x-test-user', init.user) + if (init.key) headers.set('x-test-key', init.key) + if (init.authorization) headers.set('authorization', init.authorization) + if (init.json !== undefined) headers.set('content-type', 'application/json') + return app.fetch( + new Request(`http://test${path}`, { + method: init.method ?? 'GET', + headers, + ...(init.json !== undefined ? { body: JSON.stringify(init.json) } : {}), + }), + ) + } + const { db } = h.ports + /** A user with a personal org `` (and membership of `org` as owner via h.member). */ + const user = async (id: string) => { + await db.insert(schema.user).values({ id, name: id, email: `${id}@example.org` }) + await db + .insert(schema.organization) + .values({ id: `p-${id}`, name: id, slug: id, isDefault: true }) + await db.insert(schema.member).values({ organizationId: `p-${id}`, userId: id, role: 'owner' }) + return id + } + return { h, call, db, user } +} diff --git a/packages/server/test/kf.test.ts b/packages/server/test/kf.test.ts new file mode 100644 index 0000000..4dac200 --- /dev/null +++ b/packages/server/test/kf.test.ts @@ -0,0 +1,209 @@ +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import { createKf } from '../src/auth/kf.js' +import * as schema from '../src/db/schema.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) + +describe('KF Auth client', () => { + it('refreshes an expired token, reads the role, and finds KF orgs', async () => { + const h = await harness() + const { db } = h.ports + await db.insert(schema.user).values({ id: 'u1', name: 'Ada', email: 'ada@example.org' }) + await db.insert(schema.account).values({ + id: 'a1', + accountId: 'kf-user-old', + providerId: 'kf-auth', + userId: 'u1', + accessToken: 'stale', + refreshToken: 'r1', + accessTokenExpiresAt: new Date(Date.now() - 1000), + }) + const calls: string[] = [] + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const auth = new Headers(init?.headers).get('authorization') + calls.push(`${init?.method ?? 'GET'} ${url.replace('http://kf', '')} ${auth ?? ''}`.trim()) + if (url.endsWith('/oauth2/token')) { + const body = new URLSearchParams(String(init?.body)) + expect(body.get('refresh_token')).toBe('r1') + return json({ access_token: 'fresh', refresh_token: 'r2', expires_in: 3600 }) + } + if (url.endsWith('/oauth2/userinfo')) { + return auth === 'Bearer fresh' + ? json({ name: 'Ada L', 'https://knowledgefutures.org/role': 'admin' }) + : json({}, 401) + } + // The stored KF id finds no orgs; the email search finds the right user. + if (url.endsWith('/internal/users/kf-user-old/orgs')) return json({ orgs: [] }) + if (url.includes('/internal/users/search')) + return json({ users: [{ id: 'x' }, { id: 'k2' }] }) + if (url.endsWith('/internal/users/x')) return json({ email: 'someone@else.org' }) + if (url.endsWith('/internal/users/k2')) return json({ email: 'ADA@example.org' }) + if (url.endsWith('/internal/users/k2/orgs')) { + return json([ + { id: 'kf-shared', name: 'S', slug: 's', type: 'shared', role: 'member' }, + { id: 'kf-own', name: 'A', slug: 'a', type: 'personal', role: 'owner' }, + ]) + } + return json({}, 404) + }) as typeof fetch + + const kf = createKf( + db, + { internalUrl: 'http://kf', clientId: 'c', clientSecret: 's', internalApiKey: 'ik' }, + fetcher, + ) + expect(await kf.profile('u1')).toEqual({ name: 'Ada L', image: null, role: 'admin' }) + const [acct] = await db.select().from(schema.account).where(eq(schema.account.id, 'a1')) + expect(acct).toMatchObject({ accessToken: 'fresh', refreshToken: 'r2' }) + + // Cached for the shell; role() reads fresh, with the stored (now valid) token. + calls.length = 0 + await kf.profile('u1') + expect(calls).toEqual([]) + expect(await kf.role('u1')).toBe('admin') + expect(calls).toEqual(['GET /api/auth/oauth2/userinfo Bearer fresh']) + + expect(await kf.defaultOrgId('u1')).toBe('kf-own') + expect(await kf.entitled('u1', 'kf-shared')).toBe(true) + expect(await kf.entitled('u1', 'kf-else')).toBe(false) + expect( + calls.filter((c) => c.includes('/internal/')).every((c) => c.endsWith('Bearer ik')), + ).toBe(true) + + expect(kf.isInternalCall('Bearer ik')).toBe(true) + expect(kf.isInternalCall('Bearer i')).toBe(false) + expect(kf.isInternalCall(undefined)).toBe(false) + }) + + it('without the internal key, has no orgs and accepts no internal calls', async () => { + const h = await harness() + const kf = createKf( + h.ports.db, + { internalUrl: 'http://kf', clientId: 'c', clientSecret: 's' }, + (() => { + throw new Error('no network') + }) as typeof fetch, + ) + expect(await kf.orgs('u1')).toEqual([]) + expect(await kf.profile('u1')).toBeNull() + expect(kf.isInternalCall('Bearer ')).toBe(false) + }) + + describe('refreshing an expired token', () => { + async function setup() { + const h = await harness() + const { db } = h.ports + await db.insert(schema.user).values({ id: 'u1', name: 'Ada', email: 'ada@example.org' }) + await db.insert(schema.account).values({ + id: 'a1', + accountId: 'kf-u1', + providerId: 'kf-auth', + userId: 'u1', + accessToken: 'stale', + refreshToken: 'r1', + accessTokenExpiresAt: new Date(Date.now() - 1000), + }) + return db + } + const userinfo = (init?: RequestInit, valid = ['fresh', 'other']) => + valid.some((t) => new Headers(init?.headers).get('authorization') === `Bearer ${t}`) + ? json({ 'https://knowledgefutures.org/role': 'admin' }) + : json({}, 401) + + it('refreshes once for concurrent requests, though KF Auth rotates refresh tokens', async () => { + const db = await setup() + const used = new Set() + let refreshes = 0 + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/oauth2/token')) { + refreshes++ + const token = new URLSearchParams(String(init?.body)).get('refresh_token')! + await new Promise((r) => setTimeout(r, 20)) + // A refresh token works once. + if (used.has(token)) return json({ error: 'invalid_grant' }, 400) + used.add(token) + return json({ access_token: 'fresh', refresh_token: 'r2', expires_in: 3600 }) + } + if (url.endsWith('/oauth2/userinfo')) return userinfo(init) + return json({}, 404) + }) as typeof fetch + const kf = createKf( + db, + { internalUrl: 'http://kf', clientId: 'c', clientSecret: 's' }, + fetcher, + ) + const roles = await Promise.all(Array.from({ length: 8 }, () => kf.role('u1'))) + expect(roles).toEqual(Array(8).fill('admin')) + expect(refreshes).toBe(1) + }) + + it('after losing the refresh to another isolate, uses the token it stored', async () => { + const db = await setup() + const warn = console.warn + const warnings: string[] = [] + console.warn = (...a: unknown[]) => void warnings.push(a.map(String).join(' ')) + try { + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/oauth2/token')) { + // The other isolate's refresh won and stored its token. + await db + .update(schema.account) + .set({ accessToken: 'other', accessTokenExpiresAt: new Date(Date.now() + 3600_000) }) + .where(eq(schema.account.id, 'a1')) + return json({ error: 'invalid_grant' }, 400) + } + if (url.endsWith('/oauth2/userinfo')) return userinfo(init) + return json({}, 404) + }) as typeof fetch + const kf = createKf( + db, + { internalUrl: 'http://kf', clientId: 'c', clientSecret: 's' }, + fetcher, + { rereadDelayMs: 0 }, + ) + expect(await kf.role('u1')).toBe('admin') + // The failure is logged with its status. + expect(warnings.some((w) => w.includes('refresh') && w.includes('400'))).toBe(true) + } finally { + console.warn = warn + } + }) + + it('keeps the role read in the last 30 s while KF Auth is unreachable', async () => { + const db = await setup() + let down = false + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (down) throw new Error('connect ECONNREFUSED') + const url = String(input) + if (url.endsWith('/oauth2/token')) + return json({ access_token: 'fresh', refresh_token: 'r2', expires_in: 3600 }) + if (url.endsWith('/oauth2/userinfo')) return userinfo(init) + return json({}, 404) + }) as typeof fetch + const warn = console.warn + console.warn = () => {} + try { + const kf = createKf( + db, + { internalUrl: 'http://kf', clientId: 'c', clientSecret: 's' }, + fetcher, + { rereadDelayMs: 0 }, + ) + expect(await kf.role('u1')).toBe('admin') + down = true + expect(await kf.role('u1')).toBe('admin') + } finally { + console.warn = warn + } + }) + }) +}) diff --git a/packages/server/test/limits.test.ts b/packages/server/test/limits.test.ts new file mode 100644 index 0000000..9de1e90 --- /dev/null +++ b/packages/server/test/limits.test.ts @@ -0,0 +1,228 @@ +import { createHash } from 'node:crypto' + +import { afterAll, describe, expect, it } from 'vitest' + +import { createApp } from '../src/app.js' +import * as schema from '../src/db/schema.js' +import { memoryRateLimiter, requestCost } from '../src/lib/limits.js' +import { cleanup, harness } from './harness.js' +import { setup } from './kf-app.js' + +afterAll(cleanup) + +const sha = (s: string) => createHash('sha256').update(s).digest('hex') + +/** A tar archive's regular files by name (ustar; pax headers skipped). */ +function untar(bytes: Uint8Array): Map { + const out = new Map() + const text = (a: number, b: number) => + new TextDecoder().decode(bytes.subarray(a, b)).replace(/\0.*$/s, '') + for (let at = 0; at + 512 <= bytes.length;) { + const name = text(at, at + 100) + if (!name) break + const size = parseInt(text(at + 124, at + 136).trim(), 8) + const kind = text(at + 156, at + 157) + if (kind === '0' || kind === '') + out.set(name, new TextDecoder().decode(bytes.subarray(at + 512, at + 512 + size))) + at += 512 + Math.ceil(size / 512) * 512 + } + return out +} +const Doc = { type: 'object', properties: { title: { type: 'string' }, pdf: { type: 'object' } } } + +describe('rate limits', () => { + it('budgets requests per IP and per user, not counting a page’s own API calls', async () => { + const h = await harness() + const ports = { ...h.ports, rateLimit: memoryRateLimiter({ anon: 3, user: 5, page: 5 }) } + const app = createApp(() => ({ + ports, + config: { appUrl: 'http://test', deployment: 'test' }, + authenticate: async (req) => { + const user = req.headers.get('x-test-user') + return user ? { userId: user, scope: 'session', collectionIds: null } : null + }, + // A page that makes four API calls of its own. + renderPage: async (req, api) => { + for (let i = 0; i < 4; i++) await api(new Request('http://test/api/health', req)) + return new Response('page') + }, + })) + const get = (path: string, headers: Record = {}) => + app.fetch(new Request(`http://test${path}`, { headers })) + const ip = (a: string) => ({ 'cf-connecting-ip': a }) + + // A page spends from the page budget, its own four API calls from nothing. + expect((await get('/somepage', ip('1.1.1.1'))).status).toBe(200) + for (let i = 0; i < 3; i++) expect((await get('/api/health', ip('1.1.1.1'))).status).toBe(200) + const limited = await get('/api/health', ip('1.1.1.1')) + expect(limited.status).toBe(429) + expect(limited.headers.get('retry-after')).toBe('60') + expect((await get('/api/health', ip('2.2.2.2'))).status).toBe(200) + + // Signed in, the budget is the user's, wherever they are. + for (let i = 0; i < 5; i++) { + expect((await get('/api/health', { ...ip('1.1.1.1'), 'x-test-user': 'u1' })).status).toBe(200) + } + expect((await get('/api/health', { 'x-test-user': 'u1' })).status).toBe(429) + // The rightmost forwarded hop is the address, not the client-written leftmost. + const fwd = (v: string) => ({ 'x-forwarded-for': v }) + for (let i = 0; i < 3; i++) + expect((await get('/api/health', fwd(`9.9.9.${i}, 3.3.3.3`))).status).toBe(200) + expect((await get('/api/health', fwd('9.9.9.9, 3.3.3.3'))).status).toBe(429) + + // Expensive reads cost more; ARK resolution and record pages count too. + expect(requestCost('/api/collections/org/c/export')).toBe(20) + expect(requestCost('/api/collections/org/c/versions/3/records.ndjson.gz')).toBe(10) + expect(requestCost('/api/records/abc/provenance')).toBe(5) + expect(requestCost('/records/abc')).toBe(5) + expect(requestCost('/ark:/12345/x')).toBe(2) + expect(requestCost('/api/health')).toBe(1) + expect((await get('/ark:/12345/x', ip('4.4.4.4'))).status).not.toBe(429) + expect((await get('/ark:/12345/x', ip('4.4.4.4'))).status).not.toBe(429) + expect((await get('/ark:/12345/x', ip('4.4.4.4'))).status).toBe(429) + // The auth routes have a budget of their own, per IP. + for (let i = 0; i < 5; i++) + expect((await get('/api/auth/get-session', ip('5.5.5.5'))).status).not.toBe(429) + expect((await get('/api/auth/get-session', ip('5.5.5.5'))).status).toBe(429) + expect((await get('/api/health', ip('5.5.5.5'))).status).toBe(200) + }) +}) + +describe('denylist and abuse reports', () => { + it('withholds blocked files and records everywhere they are served', async () => { + const { h, call, user } = await setup() + await user('u1') // a steward in the fake KF Auth + const owner = await h.member('u1') + await h.collection('docs') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/docs' + const bad = 'bad bytes' + const good = 'good bytes' + for (const b of [bad, good]) { + await h.request(`${base}/files/${sha(b)}`, { method: 'PUT', user: owner, body: b }) + } + const open = await h.request(`${base}/push`, { + method: 'POST', + user: owner, + json: { schemas: { Doc } }, + }) + const sid = (await open.json()).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user: owner, + ndjson: [ + { id: 'a', type: 'Doc', data: { title: 'Spam', pdf: { $file: `sha256:${sha(bad)}` } } }, + { id: 'b', type: 'Doc', data: { title: 'Fine', pdf: { $file: `sha256:${sha(good)}` } } }, + ], + }) + expect( + (await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user: owner })).status, + ).toBe(201) + const listing = async () => + ((await (await call(`${base}/versions/latest/records?type=Doc`)).json()).records ?? []) as { + id: string + hash: string + }[] + const spam = (await listing()).find((r) => r.id === 'a')! + + // Anyone may report; only stewards see reports or change the denylist. + expect( + (await call('/api/abuse-reports', { method: 'POST', json: { reason: 'spam' } })).status, + ).toBe(400) + const report = await call('/api/abuse-reports', { + method: 'POST', + json: { hash: `sha256:${sha(bad)}`, reason: 'spam file', contact: 'x@example.org' }, + }) + expect(report.status).toBe(201) + const { id: reportId } = await report.json() + await user('u2') + expect((await call('/api/admin/abuse-reports', { user: 'u2' })).status).toBe(403) + const open1 = await (await call('/api/admin/abuse-reports', { user: 'u1' })).json() + expect(open1.reports.map((r: { id: string }) => r.id)).toEqual([reportId]) + const block = (json: unknown, u = 'u1') => + call('/api/admin/denylist', { method: 'POST', user: u, json }) + expect((await block({ hash: sha(bad), kind: 'file', reason: 'x' }, 'u2')).status).toBe(403) + expect((await block({ hash: 'nope', kind: 'file', reason: 'x' })).status).toBe(400) + + // The global route finds files through the reference log. + await h.drain() + expect((await call(`${base}/files/${sha(bad)}`)).status).toBe(302) + expect((await call(`/api/collections/files/${sha(bad)}`)).status).toBe(302) + expect((await block({ hash: sha(bad), kind: 'file', reason: 'spam' })).status).toBe(201) + expect((await block({ hash: spam.hash, kind: 'record', reason: 'spam' })).status).toBe(201) + + // The block resolved the report. + expect((await (await call('/api/admin/abuse-reports', { user: 'u1' })).json()).reports).toEqual( + [], + ) + + // Files: the redirect, the global route and presign all refuse it. + expect((await call(`${base}/files/${sha(bad)}`)).status).toBe(451) + expect((await call(`/api/collections/files/${sha(bad)}`)).status).toBe(451) + const presigned = await ( + await call(`${base}/files/presign`, { + method: 'POST', + json: { hashes: [sha(bad), sha(good)] }, + }) + ).json() + expect(presigned[sha(bad)]).toBeNull() + expect(presigned[sha(good)]).toEqual(expect.any(String)) + // Only to those who may read it: anyone else gets the 404 an unknown file + // gets, so a 451 never confirms a file exists or is blocked. + await h.collection('empty') + await h.ports.db.update(schema.collections).set({ public: true }) + expect((await call(`/api/collections/org/empty/files/${sha(bad)}`)).status).toBe(404) + expect( + (await call(`/api/collections/org/empty/files/${sha(bad)}`, { method: 'HEAD' })).status, + ).toBe(404) + expect((await call(`${base}/files/${sha(bad)}`, { method: 'HEAD' })).status).toBe(451) + expect((await block({ hash: sha('unheld'), kind: 'file', reason: 'x' })).status).toBe(201) + expect((await call(`${base}/files/${sha('unheld')}`)).status).toBe(404) + expect((await call(`/api/collections/files/${sha('unheld')}`)).status).toBe(404) + expect((await call('/api/collections/files/not-a-hash')).status).toBe(404) + // Records: listings and NDJSON leave it out; the batch read returns nothing for it. + expect((await listing()).map((r) => r.id)).toEqual(['b']) + const ndjson = await (await call(`${base}/versions/latest/records.ndjson`)).text() + expect(ndjson).toContain('Fine') + expect(ndjson).not.toContain('Spam') + const batch = await ( + await call('/api/records/batch', { method: 'POST', json: { hashes: [spam.hash] } }) + ).text() + expect(batch).toBe('') + // Export: the archive is whole, the record left out and the file listed as withheld. + const tar = untar(new Uint8Array(await (await call(`${base}/export?format=tar`)).arrayBuffer())) + const lines = tar.get('records/Doc.ndjson')!.trim().split('\n') + expect(lines.map((l) => JSON.parse(l).id)).toEqual(['b']) + expect(tar.has(`files/${sha(bad)}`)).toBe(false) + expect(tar.get(`files/${sha(good)}`)).toBe(good) + const manifest = JSON.parse(tar.get('manifest.json')!) + expect(manifest.files_withheld).toEqual([sha(bad)]) + expect(manifest.version.recordCount).toBe(1) + + // Unblocking serves it again. + expect( + (await call(`/api/admin/denylist/${sha(bad)}`, { method: 'DELETE', user: 'u1' })).status, + ).toBe(200) + expect((await call(`${base}/files/${sha(bad)}`)).status).toBe(302) + const entries = (await (await call('/api/admin/denylist', { user: 'u1' })).json()).entries + expect(entries.map((e: { kind: string }) => e.kind).sort()).toEqual(['file', 'record']) + }) + + it('lets stewards dismiss a report', async () => { + const { call, user } = await setup() + await user('u1') + const r = await call('/api/abuse-reports', { + method: 'POST', + json: { url: 'https://example.org/x', reason: 'phishing' }, + }) + const { id } = await r.json() + const patch = (status: string) => + call(`/api/admin/abuse-reports/${id}`, { method: 'PATCH', user: 'u1', json: { status } }) + expect((await patch('blocked')).status).toBe(400) + expect((await patch('dismissed')).status).toBe(200) + const dismissed = await ( + await call('/api/admin/abuse-reports?status=dismissed', { user: 'u1' }) + ).json() + expect(dismissed.reports).toEqual([expect.objectContaining({ id, resolvedBy: 'u1' })]) + }) +}) diff --git a/packages/server/test/manage.test.ts b/packages/server/test/manage.test.ts new file mode 100644 index 0000000..43d532e --- /dev/null +++ b/packages/server/test/manage.test.ts @@ -0,0 +1,330 @@ +import { readHead, verifyLog } from '@underlay/protocol' +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { cleanup, harness } from './harness.js' +import { setup } from './kf-app.js' + +afterAll(cleanup) + +const Author = { type: 'object', properties: { name: { type: 'string' } } } + +async function json(res: Response) { + return (await res.json()) as any +} + +describe('collection management', () => { + it('creates, updates, edits metadata, forks, transfers and deletes', async () => { + const h = await harness() + const user = await h.member() + let res = await h.request('/api/accounts/org/collections', { + method: 'POST', + user, + json: { slug: 'new-one', name: 'New One', public: true }, + }) + expect(res.status).toBe(201) + expect( + ( + await h.request('/api/accounts/org/collections', { + method: 'POST', + user, + json: { slug: 'new-one' }, + }) + ).status, + ).toBe(409) + expect( + ( + await h.request('/api/accounts/org/collections', { + method: 'POST', + user, + json: { slug: 'API' }, + }) + ).status, + ).toBe(422) + expect( + (await h.request('/api/accounts/org/collections', { method: 'POST', json: { slug: 'x' } })) + .status, + ).toBe(401) + + const base = '/api/collections/org/new-one' + const sid = ( + await json( + await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Author }, metadata: { title: 'T' } }, + }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [ + { id: 'a', type: 'Author', data: { name: 'A' } }, + { id: 'b', type: 'Author', data: { name: 'B' }, private: true }, + ], + }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + + // Metadata and fork bodies follow the input rules, as pushes do. + for (const [path, body] of [ + [`${base}/metadata`, '{"description":"a","description":"b"}'], + [`${base}/metadata`, '{"count":9007199254740993}'], + [`${base}/fork`, '{"targetOrg":"org","targetOrg":"org","slug":"dup"}'], + ] as const) { + const bad = await h.request(path, { + method: 'POST', + user, + body, + headers: { 'content-type': 'application/json' }, + }) + expect(bad.status).toBe(400) + expect((await json(bad)).error).toMatch(/duplicate_key|unsafe_integer/) + } + + // Metadata edit: a patch version reusing every set. + res = await h.request(`${base}/metadata`, { + method: 'POST', + user, + json: { description: 'Edited' }, + }) + expect(res.status).toBe(201) + expect((await json(res)).semver).toBe('v1.0.1') + const detail = await json(await h.request(base)) + expect(detail.latestVersion.metadata).toEqual({ title: 'T', description: 'Edited' }) + expect(detail.description).toBe('Edited') + + // null clears a field (the settings page sends every field, empty ones as null). + res = await h.request(`${base}/metadata`, { + method: 'POST', + user, + json: { title: null, readme: null, description: 'Edited' }, + }) + expect(res.status).toBe(201) + expect((await json(res)).semver).toBe('v1.0.2') + expect((await json(await h.request(base))).latestVersion.metadata).toEqual({ + description: 'Edited', + }) + res = await h.request(`${base}/metadata`, { + method: 'POST', + user, + json: { title: null, description: 'Edited' }, + }) + expect(await json(res)).toEqual({ semver: 'v1.0.2', unchanged: true }) + + // Fork into another org by a member there: public set only, no data copied. + await h.ports.db.insert(schema.user).values({ id: 'u2', name: 'u2', email: 'u2@example.org' }) + await h.ports.db.insert(schema.organization).values({ id: 'org2', name: 'Two', slug: 'two' }) + await h.ports.db + .insert(schema.member) + .values({ organizationId: 'org2', userId: 'u2', role: 'owner' }) + const putsBefore = h.bucket.puts + res = await h.request(`${base}/fork`, { + method: 'POST', + user: 'u2', + json: { targetOrg: 'two' }, + }) + expect(res.status).toBe(201) + const fork = await json(res) + expect(fork).toMatchObject({ + owner: 'two', + slug: 'new-one', + forkedFrom: { version: 'v1.0.2' }, + version: { recordCount: 1 }, + }) + expect(h.bucket.puts - putsBefore).toBeLessThanOrEqual(4) // root, log entry, head.json + const [child] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, fork.id)) + const repo = await h.ports.stores.forCollection(child!.id) + const [srcHead] = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.semver, 'v1.0.2')) + const forkVersion = ( + await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, child!.id)) + )[0]! + const srcRoot = await repo.root(srcHead!.hash) + const forkRoot = await repo.root(forkVersion.hash) + expect(forkRoot.public).toEqual(srcRoot.public) + expect(forkRoot.private).toBe(null) + expect((await readHead(repo, child!.id))?.seq).toBe(1) + await expect(verifyLog(repo, child!.id, [h.signer.publicKey])).resolves.toBeTruthy() + // The fork can push on top of its first version. + const fsid = ( + await json( + await h.request('/api/collections/two/new-one/push', { + method: 'POST', + user: 'u2', + json: { base: 'v1.0.0' }, + }), + ) + ).session_id + await h.request(`/api/collections/two/new-one/push/${fsid}/records`, { + method: 'POST', + user: 'u2', + ndjson: [{ id: 'c', type: 'Author', data: { name: 'C' } }], + }) + expect( + ( + await h.request(`/api/collections/two/new-one/push/${fsid}/commit`, { + method: 'POST', + user: 'u2', + }) + ).status, + ).toBe(201) + + // Rename and visibility; transfer needs admin in both orgs. + res = await h.request(base, { method: 'PATCH', user, json: { slug: 'renamed', public: false } }) + expect(await json(res)).toEqual({ ok: true, slug: 'renamed' }) + expect((await h.request('/api/collections/org/renamed')).status).toBe(404) + expect( + ( + await h.request('/api/collections/org/renamed/transfer', { + method: 'POST', + user, + json: { targetOrgSlug: 'two' }, + }) + ).status, + ).toBe(403) + await h.ports.db + .insert(schema.member) + .values({ organizationId: 'org2', userId: user, role: 'admin' }) + res = await h.request('/api/collections/org/renamed/transfer', { + method: 'POST', + user, + json: { targetOrgSlug: 'two' }, + }) + expect(await json(res)).toEqual({ ok: true, newOwner: 'two' }) + expect( + (await h.request('/api/collections/two/renamed', { method: 'DELETE', user })).status, + ).toBe(200) + expect((await h.request('/api/collections/two/renamed', { user })).status).toBe(404) + }) + + it('keeps the description given at creation until a version gives one', async () => { + const h = await harness() + const user = await h.member() + const res = await h.request('/api/accounts/org/collections', { + method: 'POST', + user, + json: { slug: 'described', description: ' Authors and books ', public: true }, + }) + expect(res.status).toBe(201) + const base = '/api/collections/org/described' + const push = async (metadata: Record, name: string) => { + const sid = ( + await json( + await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Author }, metadata }, + }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [{ id: 'a', type: 'Author', data: { name } }], + }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + } + expect((await json(await h.request(base))).description).toBe('Authors and books') + await push({ title: 'T' }, 'A') + expect((await json(await h.request(base))).description).toBe('Authors and books') + await push({ title: 'T', description: 'From the push' }, 'B') + expect((await json(await h.request(base))).description).toBe('From the push') + }) + + it("returns the collection's ARK and each version's, and members see who pushed", async () => { + const h = await harness() + const user = await h.member() + await h.request('/api/accounts/org/collections', { + method: 'POST', + user, + json: { slug: 'with-ark', public: true }, + }) + const base = '/api/collections/org/with-ark' + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Author } } }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [{ id: 'a', type: 'Author', data: { name: 'A' } }], + }) + await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + const col = await json(await h.request(base)) + expect(col.ark).toMatch(/\/ark:\d+\/ul\w+$/) + expect(col.latestVersion.ark).toBe(`${col.ark}.v1.0.0`) + // Who pushed: a name for members, nothing for the public. + expect(col.latestVersion).not.toHaveProperty('pushedByName') + const mine = await json(await h.request(base, { user })) + expect(mine.latestVersion.pushedByName).toBe('u1') + await h.ports.db + .insert(schema.organization) + .values({ id: 'personal-u1', name: 'u1', slug: 'u1-home', isDefault: true }) + await h.ports.db + .insert(schema.member) + .values({ organizationId: 'personal-u1', userId: user, role: 'owner' }) + const linked = await json(await h.request(base, { user })) + expect(linked.latestVersion.pushedBySlug).toBe('u1-home') + const [mineListed] = await json(await h.request(`${base}/versions`, { user })) + expect(mineListed.pushedByName).toBe('u1') + const [listed] = await json(await h.request(`${base}/versions`)) + expect(listed.ark).toBe(`${col.ark}.v1.0.0`) + expect((await json(await h.request(`${base}/versions/1.0.0`))).ark).toBe(`${col.ark}.v1.0.0`) + + // A disabled ARK isn't shown. + await h.request(`${base}/ark`, { method: 'PATCH', user, json: { enabled: false } }) + expect((await json(await h.request(base))).ark).toBeNull() + }) + + it('lets a key confined to a collection write to it but never manage it', async () => { + const { h, call } = await setup() + const owner = await h.member('u1') + const c = await h.collection('c') + const base = '/api/collections/org/c' + for (const key of [`scoped-admin:${c.id}`, `scoped-write:${c.id}`]) { + const as = (method: string, path: string, json?: unknown) => + call(path, { method, user: owner, key, ...(json !== undefined ? { json } : {}) }) + for (const res of [ + await as('PATCH', base, { public: true }), + await as('DELETE', base), + await as('POST', `${base}/transfer`, { targetOrgSlug: 'org' }), + ]) { + expect(res.status).toBe(403) + expect((await json(res)).error).toBe('This key cannot manage collections') + } + // Webhooks and mirrors are an owner's or admin's, which the key never is. + expect((await as('GET', `${base}/webhooks`)).status).toBe(403) + expect((await as('POST', `${base}/webhooks`, { url: 'https://example.org/h' })).status).toBe( + 403, + ) + expect((await as('POST', `${base}/placements`, { locationId: 'x' })).status).toBe(403) + // Name, slug and metadata are a writer's. + expect((await as('PATCH', base, { name: 'Renamed' })).status).toBe(200) + expect((await as('POST', `${base}/metadata`, { description: 'd' })).status).toBe(422) + } + const [row] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, c.id)) + expect(row).toMatchObject({ public: false, name: 'Renamed', deletedAt: null }) + // The owner's session still manages it. + expect( + (await call(base, { method: 'PATCH', user: owner, json: { public: true } })).status, + ).toBe(200) + }) +}) diff --git a/packages/server/test/mirrors.test.ts b/packages/server/test/mirrors.test.ts new file mode 100644 index 0000000..f905a61 --- /dev/null +++ b/packages/server/test/mirrors.test.ts @@ -0,0 +1,491 @@ +import { createHash } from 'node:crypto' + +import { + iterate, + openRepo, + PrefixedStore, + readCollectionInfo, + readHead, + recordTree, + type Repo, + RepoSource, + s3Store, + verifyLog, + verifyTree, +} from '@underlay/protocol' +import { eq } from 'drizzle-orm' +import { afterAll, afterEach, describe, expect, it } from 'vitest' + +import { type FakeS3, startFakeS3 } from '../../protocol/test/stores/fake-s3.js' +import * as schema from '../src/db/schema.js' +import { mirrorConfig } from '../src/locations/mirror.js' +import { cleanup, type Harness, harness } from './harness.js' + +const fakes: FakeS3[] = [] +afterAll(async () => { + for (const f of fakes.splice(0)) await f.close() + await cleanup() +}) +const defaults = { ...mirrorConfig } +afterEach(() => Object.assign(mirrorConfig, defaults)) + +async function fake(opts: { publicRead?: boolean; region?: string } = {}) { + const f = await startFakeS3('bucket', 0, opts) + fakes.push(f) + return f +} + +const sha = (s: string) => createHash('sha256').update(s).digest('hex') +const Book = { + type: 'object', + properties: { title: { type: 'string' }, cover: { type: 'object' } }, + required: ['title'], +} +const book = (id: string, title: string, extra: Record = {}) => ({ + id, + type: 'Book', + data: { title }, + ...extra, +}) + +async function push( + h: Harness, + user: string, + path: string, + open: object, + records: unknown[], + deletes: unknown[] = [], +) { + const res = await h.request(`${path}/push`, { method: 'POST', user, json: open }) + const sid = ((await res.json()) as { session_id: string }).session_id + if (records.length) + await h.request(`${path}/push/${sid}/records`, { method: 'POST', user, ndjson: records }) + if (deletes.length) + await h.request(`${path}/push/${sid}/deletes`, { method: 'POST', user, ndjson: deletes }) + const commit = await h.request(`${path}/push/${sid}/commit`, { method: 'POST', user }) + expect(commit.status).toBe(201) + return (await commit.json()) as { semver: string; hash: string } +} + +async function addLocation( + h: Harness, + user: string, + f: FakeS3, + prefix: string, + extra: object = {}, +) { + const res = await h.request('/api/orgs/org/locations', { + method: 'POST', + user, + json: { + name: prefix, + endpoint: f.url, + bucket: 'bucket', + prefix, + accessKeyId: 'AKID', + secretAccessKey: 'secret', + ...extra, + }, + }) + return { + status: res.status, + body: (await res.json()) as { + location: { id: string; status: string } + check: { ok: boolean; publicRead: boolean; readBack: boolean } + }, + } +} + +/** The mirror as a reader sees it: a repository in the bucket, verified. */ +function mirrorRepo(f: FakeS3, prefix: string): Repo { + const store = s3Store({ + endpoint: f.url, + bucket: 'bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + }) + return openRepo(new PrefixedStore(store, prefix)) +} + +async function placements(h: Harness, user: string, path: string) { + const res = await h.request(`${path}/placements`, { user }) + return (await res.json()) as { + headSeq: number + placements: { + id: string + role: string + sets: string + state: string + syncedSeq: number + lag: number + lastError: string | null + inherited: boolean + }[] + } +} + +async function setup() { + const h = await harness() + const user = await h.member() + const c = await h.collection('books') + const path = '/api/collections/org/books' + const cover = 'cover bytes' + expect( + (await h.request(`${path}/files/${sha(cover)}`, { method: 'PUT', user, body: cover })).status, + ).toBe(201) + const v1 = await push(h, user, path, { schemas: { Book } }, [ + ...Array.from({ length: 2500 }, (_, i) => book(`b${i}`, `T${i}`)), + book('cover', 'with a cover', { + data: { title: 'c', cover: { $file: `sha256:${sha(cover)}` } }, + }), + book('secret', 'hidden', { private: true }), + ]) + const v2 = await push( + h, + user, + path, + {}, + [book('b1', 'changed'), book('new', 'new')], + [{ type: 'Book', id: 'b2' }], + ) + return { h, user, c, path, cover, v1, v2 } +} + +describe('bucket mirrors', () => { + it('adds a location, mirrors the history, and the bucket reads as a repository', async () => { + const { h, user, c, path, cover, v2 } = await setup() + const f = await fake() + const loc = await addLocation(h, user, f, 'mirror') + expect(loc.status).toBe(201) + expect(loc.body.check).toMatchObject({ ok: true, publicRead: false, readBack: true }) + const list = await (await h.request('/api/orgs/org/locations', { user })).json() + expect(JSON.stringify(list)).not.toContain('secret') + + const created = await h.request(`${path}/placements`, { + method: 'POST', + user, + json: { locationId: loc.body.location.id, sets: 'public+private' }, + }) + expect(created.status).toBe(201) + await h.drain() + const status = await placements(h, user, path) + const mirror = status.placements.find((p) => p.role === 'mirror')! + expect(mirror).toMatchObject({ + syncedSeq: 2, + lag: 0, + state: 'active', + lastError: null, + inherited: false, + }) + + // A third party with read access sees a complete, verifiable repository. + const repo = mirrorRepo(f, 'mirror') + const head = await readHead(repo, c.id) + expect(head).toMatchObject({ seq: 2, versionHash: v2.hash }) + const info = await readCollectionInfo(repo, c.id) + const { entries } = await verifyLog(repo, c.id, info!.keys) + expect(entries).toHaveLength(2) + const root = await repo.root(v2.hash) + const priv = await repo.privateSet(root.private!) + const src = new RepoSource(recordTree, repo) + for (const t of [root.public.types.Book!, priv.types.Book!]) { + expect((await verifyTree(src, t.root)).errors).toEqual([]) + let n = 0 + for await (const e of iterate(src, t.root, { payloads: true })) if (e.body) n++ + expect(n).toBe(t.count) + } + expect(root.public.types.Book!.count).toBe(2501) + expect(priv.types.Book!.count).toBe(1) + const file = await repo.blobs.get(`files/${sha(cover)}`) + expect(sha(await file!.text())).toBe(sha(cover)) + + // The next version copies only what changed. + const before = f.requests.length + await push(h, user, path, {}, [book('b5', 'again')]) + await h.drain() + expect( + (await placements(h, user, path)).placements.find((p) => p.role === 'mirror')!.syncedSeq, + ).toBe(3) + const puts = f.requests.slice(before).filter((r) => r.method === 'PUT').length + expect(puts).toBeLessThan(20) + }) + + it('copies in budgeted steps, and a public mirror holds no private object', async () => { + const { h, user, c, path, v2 } = await setup() + const f = await fake() + const loc = await addLocation(h, user, f, 'pub') + mirrorConfig.objectsPerJob = 3 + await h.request(`${path}/placements`, { + method: 'POST', + user, + json: { locationId: loc.body.location.id, sets: 'public' }, + }) + await h.drain() + const repo = mirrorRepo(f, 'pub') + expect((await readHead(repo, c.id))?.versionHash).toBe(v2.hash) + const root = await repo.root(v2.hash) + const src = new RepoSource(recordTree, repo) + expect((await verifyTree(src, root.public.types.Book!.root)).errors).toEqual([]) + const keys = [...f.objects.keys()].filter((k) => k.startsWith('pub/')) + // Resumed copies don't redo work: about one write per object. + const puts = f.requests.filter((r) => r.method === 'PUT' && r.url.includes('/pub/')).length + expect(puts).toBeLessThanOrEqual(keys.length + 5) + expect(keys.some((k) => k.startsWith('pub/private/'))).toBe(false) + // The private tree's nodes aren't there either. + const server = await h.ports.stores.forCollection(c.id) + const sroot = await server.root(v2.hash) + const privRoot = (await server.privateSet(sroot.private!)).types.Book!.root! + expect( + f.objects.has(`bucket/pub/nodes/${privRoot}`) || keys.includes(`pub/nodes/${privRoot}`), + ).toBe(false) + }) + + it('refuses private sets on a bucket anyone can read, and broken locations', async () => { + const { h, user, path } = await setup() + const open = await fake({ publicRead: true }) + const loc = await addLocation(h, user, open, 'open') + expect(loc.body.check.publicRead).toBe(true) + const priv = await h.request(`${path}/placements`, { + method: 'POST', + user, + json: { locationId: loc.body.location.id, sets: 'public+private' }, + }) + expect(priv.status).toBe(422) + const pub = await h.request(`${path}/placements`, { + method: 'POST', + user, + json: { locationId: loc.body.location.id, sets: 'public' }, + }) + expect(pub.status).toBe(201) + + // A location whose first check fails isn't kept, and the answer says why. + const f = await fake() + const broken = await addLocation(h, user, f, 'x', { bucket: 'no-such-bucket' }) + expect(broken.status).toBe(422) + expect(broken.body.check.ok).toBe(false) + expect((broken.body as unknown as { error: string }).error).toMatch( + /^No bucket named "no-such-bucket" at /, + ) + const rows = await h.ports.db + .select() + .from(schema.storageLocations) + .where(eq(schema.storageLocations.bucket, 'no-such-bucket')) + expect(rows).toEqual([]) + }) + + it('takes the region from the endpoint, and from the bucket when that is wrong', async () => { + const { h, user } = await setup() + const f = await fake({ region: 'eu-west-2' }) + // The fake's endpoint names no region: us-east-1 first, then the bucket's answer. + const loc = await addLocation(h, user, f, 'r') + expect(loc.body.check.ok).toBe(true) + const [row] = await h.ports.db + .select() + .from(schema.storageLocations) + .where(eq(schema.storageLocations.id, loc.body.location.id)) + expect(row!.region).toBe('eu-west-2') + // A region given in the body is ignored; with no region named, none is shown. + const named = await addLocation(h, user, await fake(), 'n', { region: 'mars-1' }) + expect((named.body.location as { region?: string }).region).toBeNull() + + const { regionFor, namedRegion, checkEndpoint } = await import('../src/locations/locations.js') + expect(namedRegion('https://minio.example.org')).toBeNull() + expect(namedRegion('https://s3.amazonaws.com')).toBe('us-east-1') + expect(regionFor('https://s3.eu-west-2.amazonaws.com')).toBe('eu-west-2') + expect(regionFor('https://s3-ap-southeast-1.amazonaws.com')).toBe('ap-southeast-1') + expect(regionFor('https://s3.dualstack.us-west-2.amazonaws.com')).toBe('us-west-2') + expect(regionFor('https://s3.us-west-004.backblazeb2.com')).toBe('us-west-004') + expect(regionFor('https://abc.r2.cloudflarestorage.com')).toBe('auto') + expect(regionFor('https://s3.amazonaws.com')).toBe('us-east-1') + expect(regionFor('https://minio.example.org')).toBe('us-east-1') + // Outside dev and test: https only, and no private hosts. + expect(checkEndpoint('https://s3.eu-west-2.amazonaws.com')).toBeNull() + for (const bad of [ + 'http://s3.example.org', + 'https://localhost:9000', + 'https://127.0.0.1', + 'https://10.0.0.5', + 'https://[::1]', + 'https://169.254.169.254', + 'https://minio.internal', + 'https://minio', + 'https://s3.example.org/bucket', + 'https://key:secret@s3.example.org', + ]) + expect(checkEndpoint(bad), bad).not.toBeNull() + // Messages are for people. + expect(checkEndpoint('http://s3.example.org')).toBe('The endpoint must start with https://.') + expect(checkEndpoint('https://localhost')).toMatch(/must be a public address/) + expect(checkEndpoint('http://127.0.0.1:9000', true)).toBeNull() + }) + + it('fails a location whose lifecycle rules delete under its prefix, and warns on others', async () => { + const { h, user } = await setup() + const f = await fake() + const rule = (body: string) => + `rEnabled${body}` + const check = async (xml: string | null | 'denied', prefix: string) => { + f.lifecycle.xml = xml + return (await addLocation(h, user, f, `l${Math.random()}`, { prefix })).body as unknown as { + error?: string + check: { ok: boolean; error: string | null; warnings: string[] } + location: { status: string; lastError: string | null } + } + } + // Expires everything: mirrors' objects would be deleted, so it isn't added. + const all = await check(rule('30'), 'ul') + expect(all.check.ok).toBe(false) + expect(all.check.error).toMatch(/deletes objects under ul\//) + expect(all.error).toBe(all.check.error) + expect(all.location).toBeUndefined() + // Expires another prefix, or only tagged objects: fine. + const other = await check( + rule('logs/1'), + 'ul', + ) + expect(other.check).toMatchObject({ ok: true, warnings: [] }) + const tagged = await check( + rule( + 'tmp11', + ), + 'ul', + ) + expect(tagged.check.ok).toBe(true) + // A path under the prefix counts too; a transition only warns. + const inner = await check( + rule('ul/files/1'), + 'ul', + ) + expect(inner.check.ok).toBe(false) + const cold = await check( + rule( + '30GLACIER', + ), + 'ul', + ) + expect(cold.check.ok).toBe(true) + expect(cold.check.warnings[0]).toMatch(/another storage class/) + expect(cold.location.lastError).toMatch(/^Warning: /) + const denied = await check('denied', '') + expect(denied.check.ok).toBe(true) + expect(denied.check.warnings[0]).toMatch(/can't read the bucket's lifecycle rules/) + }) + + it('re-checks locations daily from the sweep', async () => { + const { h, user } = await setup() + const f = await fake() + const loc = await addLocation(h, user, f, 'daily') + const id = (loc.body as { location: { id: string } }).location.id + const { recheckLocations } = await import('../src/locations/locations.js') + expect(await recheckLocations(h.ports)).toBe(0) + const dayAgo = new Date(Date.now() - 25 * 60 * 60 * 1000) + await h.ports.db + .update(schema.storageLocations) + .set({ checkedAt: dayAgo }) + .where(eq(schema.storageLocations.id, id)) + // The bucket broke meanwhile: the re-check says so. + f.lifecycle.xml = + 'xEnabled1' + expect(await recheckLocations(h.ports)).toBe(1) + await h.drain() + const [row] = await h.ports.db + .select() + .from(schema.storageLocations) + .where(eq(schema.storageLocations.id, id)) + expect(row!.status).toBe('broken') + expect(row!.checkedAt!.getTime()).toBeGreaterThan(dayAgo.getTime()) + }) + + it('records errors, and catches up once the location works again', async () => { + const { h, user, path } = await setup() + const f = await fake() + const loc = await addLocation(h, user, f, 'm') + await h.request(`${path}/placements`, { + method: 'POST', + user, + json: { locationId: loc.body.location.id, sets: 'public' }, + }) + await h.drain() + // The bucket goes away. + await h.ports.db + .update(schema.storageLocations) + .set({ bucket: 'gone' }) + .where(eq(schema.storageLocations.id, loc.body.location.id)) + await push(h, user, path, {}, [book('later', 'later')]) + await h.drain() + let m = (await placements(h, user, path)).placements.find((p) => p.role === 'mirror')! + expect(m.state).toBe('error') + expect(m.lastError).toMatch(/404|NoSuchBucket/) + expect(m.lag).toBe(1) + // Fixed: the sweep picks it up. + await h.ports.db + .update(schema.storageLocations) + .set({ bucket: 'bucket' }) + .where(eq(schema.storageLocations.id, loc.body.location.id)) + await h.ports.jobs.enqueue({ type: 'maintenance.sweep' }) + await h.drain() + m = (await placements(h, user, path)).placements.find((p) => p.role === 'mirror')! + expect(m).toMatchObject({ state: 'active', lag: 0, lastError: null }) + }) + + it('applies org defaults to existing and new collections', async () => { + const { h, user, path } = await setup() + const f = await fake() + const loc = await addLocation(h, user, f, 'org') + const res = await h.request('/api/orgs/org/placements', { + method: 'POST', + user, + json: { locationId: loc.body.location.id, sets: 'public' }, + }) + expect(res.status).toBe(201) + // The same default twice is a conflict, not a second row. + expect( + ( + await h.request('/api/orgs/org/placements', { + method: 'POST', + user, + json: { locationId: loc.body.location.id, sets: 'public' }, + }) + ).status, + ).toBe(409) + await h.drain() + // Reads and publishes make the default concrete once per collection, however often they run. + for (let i = 0; i < 3; i++) await placements(h, user, path) + const mirrors = (await placements(h, user, path)).placements.filter((p) => p.role === 'mirror') + expect(mirrors.length).toBe(1) + expect( + ( + await h.ports.db + .select() + .from(schema.placements) + .where(eq(schema.placements.locationId, loc.body.location.id)) + ).length, + ).toBe(2) // the org default and this collection's row + const inherited = mirrors[0]! + expect(inherited).toMatchObject({ syncedSeq: 2, lag: 0, inherited: true }) + // An inherited mirror goes with the default, not per collection. + expect( + (await h.request(`${path}/placements/${inherited.id}`, { method: 'DELETE', user })).status, + ).toBe(409) + await h.collection('later') + await push(h, user, '/api/collections/org/later', { schemas: { Book } }, [book('x', 'y')]) + await h.drain() + const later = await placements(h, user, '/api/collections/org/later') + expect(later.placements.find((p) => p.role === 'mirror')).toMatchObject({ + syncedSeq: 1, + lag: 0, + }) + // Removing the default stops mirroring for the org's collections. + const id = ( + (await (await h.request('/api/orgs/org/placements', { user })).json()) as { + placements: { id: string }[] + } + ).placements[0]!.id + expect( + (await h.request(`/api/orgs/org/placements/${id}`, { method: 'DELETE', user })).status, + ).toBe(204) + expect((await placements(h, user, path)).placements.some((p) => p.role === 'mirror')).toBe( + false, + ) + }) +}) diff --git a/packages/server/test/paging.test.ts b/packages/server/test/paging.test.ts new file mode 100644 index 0000000..d1801ab --- /dev/null +++ b/packages/server/test/paging.test.ts @@ -0,0 +1,175 @@ +import { + type Change, + compareUtf8, + hashRecord, + hashSchema, + type RecordEntry, + utf8ByteLength, +} from '@underlay/protocol' +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { commitVersion, type TypeInput } from '../src/versions/commit.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const Doc = { type: 'object', properties: { n: { type: 'integer' } } } +const up = (id: string, n: number): Change => { + const { hash, canonical } = hashRecord(id, 'Doc', { n }) + return { key: id, entry: { key: id, hash, size: utf8ByteLength(canonical), body: canonical } } +} +const del = (id: string): Change => ({ key: id, entry: null }) +const byKey = (cs: Change[]) => cs.sort((a, b) => compareUtf8(a.key, b.key)) +const doc = (pub: Change[], priv: Change[]): TypeInput => ({ + slug: 'Doc', + schema: Doc, + schemaHash: hashSchema(Doc), + public: byKey(pub), + private: byKey(priv), +}) +const id = (i: number) => `d${String(i).padStart(4, '0')}` + +describe('diff and manifest paging', () => { + it('pages through a long diff to exactly the one-page answer', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('docs') + const v1 = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [ + doc( + Array.from({ length: 1200 }, (_, i) => up(id(i), 0)), + Array.from({ length: 300 }, (_, i) => up(id(2000 + i), 0)), + ), + ], + metadata: null, + }) + if (v1.status !== 'committed') throw new Error(v1.status) + // Updates, deletes, additions, and records moving between sets: 50 unchanged + // (no change to a member) and 20 changed on the way. + const pub: Change[] = [] + const priv: Change[] = [] + for (let i = 0; i < 500; i++) pub.push(up(id(i), 1)) + for (let i = 500; i < 600; i++) pub.push(del(id(i))) + for (let i = 3000; i < 3200; i++) pub.push(up(id(i), 0)) + for (let i = 600; i < 670; i++) { + pub.push(del(id(i))) + priv.push(up(id(i), i < 650 ? 0 : 2)) + } + const v = v1.version + const v2 = await commitVersion(h.ports, { + collectionId: c.id, + base: { + id: v.id, + seq: v.seq, + semver: v.semver, + hash: v.hash, + publicRefsRoot: v.publicRefsRoot, + privateRefsRoot: v.privateRefsRoot, + }, + types: [doc(pub, priv)], + metadata: null, + }) + if (v2.status !== 'committed') throw new Error(v2.status) + const base = `/api/collections/org/docs/versions/${v2.version.semver}` + const get = async (path: string) => (await h.request(path, { user })).json() + + type DiffPage = { + added: { id: string }[] + updated: { id: string }[] + removed: { id: string; type: string }[] + pagination: { nextCursor: string | null } + } + const one = (await get(`${base}/diff?from=${v.semver}&limit=5000`)) as DiffPage + const ids = (p: DiffPage) => ({ + added: p.added.map((x) => x.id), + updated: p.updated.map((x) => x.id), + removed: p.removed.map((x) => x.id), + }) + expect(ids(one).added).toHaveLength(200) + expect(ids(one).updated).toHaveLength(520) + expect(ids(one).removed).toHaveLength(100) + const paged = { added: [] as string[], updated: [] as string[], removed: [] as string[] } + let cursor: string | null = null + let pages = 0 + do { + const p = (await get( + `${base}/diff?from=${v.semver}&limit=97${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + )) as DiffPage + const x = ids(p) + paged.added.push(...x.added) + paged.updated.push(...x.updated) + paged.removed.push(...x.removed) + cursor = p.pagination.nextCursor + pages++ + } while (cursor) + expect(pages).toBe(Math.ceil(820 / 97)) + expect(paged).toEqual(ids(one)) + + type Entry = { + id: string + hash: string + previousHash?: string + private?: true + previousPrivate?: boolean + } + type Manifest = { + delta: { added: Entry[]; updated: Entry[]; removed: Entry[] } + files: string[] + pagination: { nextCursor: string | null } + } + const whole = (await get(`${base}/manifest?since=${v.semver}`)) as Manifest + const lines: object[] = [] + cursor = null + let first = true + do { + const p = (await get( + `${base}/manifest?since=${v.semver}&limit=150${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + )) as Manifest + lines.push(...p.delta.added, ...p.delta.updated, ...p.delta.removed) + if (!first) expect(p.files).toEqual([]) + first = false + cursor = p.pagination.nextCursor + } while (cursor) + // The manifest also lists the 50 moves that kept their hash, as updates. + expect(lines).toHaveLength(870) + expect(new Set(lines.map((l) => JSON.stringify(l)))).toEqual( + new Set( + [...whole.delta.added, ...whole.delta.updated, ...whole.delta.removed].map((l) => + JSON.stringify(l), + ), + ), + ) + expect(whole.delta.updated).toHaveLength(570) + const moves = whole.delta.updated.filter((e) => e.previousPrivate !== undefined) + expect(moves).toHaveLength(70) + expect(moves.every((e) => e.private && e.previousPrivate === false)).toBe(true) + expect(moves.filter((e) => e.hash === e.previousHash).map((e) => e.id)).toEqual( + Array.from({ length: 50 }, (_, i) => id(600 + i)), + ) + // Private records say so; a public update carries neither flag. + expect(whole.delta.updated.find((e) => e.id === id(0))).toEqual({ + id: id(0), + type: 'Doc', + hash: up(id(0), 1).entry!.hash, + previousHash: up(id(0), 0).entry!.hash, + }) + + // A reader of the public set only sees the moves as removals. + await h.ports.db.update(schema.collections).set({ public: true }) + const pubDiff = (await ( + await h.request(`${base}/diff?from=${v.semver}&limit=5000`) + ).json()) as DiffPage + expect(ids(pubDiff).removed).toHaveLength(170) + expect(ids(pubDiff).updated).toHaveLength(500) + const pubDelta = (await ( + await h.request(`${base}/manifest?since=${v.semver}`) + ).json()) as Manifest + expect(pubDelta.delta.removed).toHaveLength(170) + expect(pubDelta.delta.updated).toHaveLength(500) + const all = [...pubDelta.delta.added, ...pubDelta.delta.updated, ...pubDelta.delta.removed] + expect(all.some((e) => e.private || e.previousPrivate !== undefined)).toBe(false) + }) +}) diff --git a/packages/server/test/parallel.test.ts b/packages/server/test/parallel.test.ts new file mode 100644 index 0000000..e433beb --- /dev/null +++ b/packages/server/test/parallel.test.ts @@ -0,0 +1,364 @@ +import { createHash } from 'node:crypto' + +import { + boundaryBytes, + recordTree, + RepoSource, + trailingZeros, + verifyTree, +} from '@underlay/protocol' +import { eq } from 'drizzle-orm' +import { afterAll, afterEach, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { drainSqliteJobs } from '../src/jobs.js' +import { assembleParallel, parallelConfig, unitLimits } from '../src/push/parallel.js' +import { isMark } from '../src/push/runs.js' +import { cleanup, type Harness, harness } from './harness.js' + +afterAll(cleanup) +const defaults = { ...parallelConfig } +const unitDefaults = { ...unitLimits } +afterEach(() => { + Object.assign(parallelConfig, defaults) + Object.assign(unitLimits, unitDefaults) +}) + +const Author = { + type: 'object', + properties: { name: { type: 'string' }, pic: { type: 'object' } }, + required: ['name'], +} +const rec = (id: string, name: string, extra: Record = {}) => { + const { pic, ...rest } = extra + return { + id, + type: 'Author', + data: { name, ...(pic ? { pic: { $file: `sha256:${sha(String(pic))}` } } : {}) }, + ...rest, + } +} +const sha = (s: string) => createHash('sha256').update(s).digest('hex') + +/** Ids that are run marks (split-key candidates), found by search. */ +function markIds(prefix: string, n: number): string[] { + const out: string[] = [] + for (let i = 0; out.length < n; i++) if (isMark(`${prefix}${i}`)) out.push(`${prefix}${i}`) + return out +} + +/** One delta push: upload in batches, commit, and wait for the jobs a parallel commit queues. */ +async function push( + h: Harness, + user: string, + base: string, + open: Record, + upserts: unknown[][], + deletes: string[][], + parallel: boolean | Partial, +) { + const res = await h.request(`${base}/push`, { method: 'POST', user, json: open }) + expect(res.status).toBe(200) + const sid = ((await res.json()) as { session_id: string }).session_id + for (const batch of upserts) { + const r = await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: batch, + }) + expect(r.status).toBe(200) + } + for (const batch of deletes) { + const r = await h.request(`${base}/push/${sid}/deletes`, { + method: 'POST', + user, + ndjson: batch.map((id) => ({ type: 'Author', id })), + }) + expect(r.status).toBe(200) + } + Object.assign( + parallelConfig, + parallel + ? { above: 0, unitEntries: 400, baseLevel: 0, ...(parallel === true ? {} : parallel) } + : { above: Infinity }, + ) + const commit = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(commit.status).toBe(parallel ? 202 : 201) + await h.drain() + const status = (await (await h.request(`${base}/push/${sid}`, { user })).json()) as { + status: string + result: Record + } + expect(status.status).toBe('committed') + return { sid, result: status.result } +} + +async function trees(h: Harness, collectionId: string) { + const [c] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, collectionId)) + const [v] = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, c!.headVersionId!)) + const repo = await h.ports.stores.forCollection(collectionId) + const root = await repo.root(v!.hash) + const priv = root.private ? await repo.privateSet(root.private) : null + const { root: pr, count: pc, bytes: pb } = root.public.types.Author! + const p = priv?.types.Author + return { + repo, + files: { public: root.public.files, private: priv?.files ?? null }, + version: { recordCount: v!.recordCount, changes: v!.changes, fileCount: v!.fileCount }, + public: { root: pr, count: pc, bytes: pb }, + private: p ? { root: p.root, count: p.count, bytes: p.bytes } : null, + } +} + +describe('parallel commit', () => { + it('keeps at most a window of units on the queue, and still commits', async () => { + const h = await harness() + const user = await h.member() + await h.collection('win') + const path = '/api/collections/org/win' + const m = markIds('w', 12) + Object.assign(unitLimits, { perSession: 3, perAccount: 3 }) + Object.assign(parallelConfig, { above: 0, unitEntries: 1 }) + const res = await h.request(`${path}/push`, { + method: 'POST', + user, + json: { schemas: { Author } }, + }) + const sid = ((await res.json()) as { session_id: string }).session_id + await h.request(`${path}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [ + ...Array.from({ length: 300 }, (_, i) => rec(`r${i}`, `n${i}`)), + ...m.map((k) => rec(k, k)), + ], + }) + expect((await h.request(`${path}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 202, + ) + const queuedUnits = async () => + (await h.ports.db.select().from(schema.jobs)).filter( + (j) => j.type === 'commit.unit' && j.status !== 'failed', + ).length + const planned = await h.ports.db.select().from(schema.commitUnits) + expect(planned.filter((u) => !u.gap).length).toBeGreaterThan(3) + let most = 0 + for (;;) { + most = Math.max(most, await queuedUnits()) + if ((await drainSqliteJobs(h.ports, { max: 1 })) === 0) break + } + expect(most).toBeLessThanOrEqual(3) + const status = (await (await h.request(`${path}/push/${sid}`, { user })).json()) as { + status: string + } + expect(status.status).toBe('committed') + }) + + it('builds the same trees as the serial commit', async () => { + const h = await harness() + const user = await h.member() + const a = await h.collection('pa') + const b = await h.collection('pb') + const pathA = '/api/collections/org/pa' + const pathB = '/api/collections/org/pb' + const m = markIds('m', 8) + const id = (i: number) => `r${String(i).padStart(5, '0')}` + const files = ['f1', 'f2', 'f3'] + for (const path of [pathA, pathB]) { + for (const f of files) { + const r = await h.request(`${path}/files/${sha(f)}`, { method: 'PUT', user, body: f }) + expect(r.status).toBe(201) + } + } + + // Push 1, no base: 12,000 records and the first five marks. + const first = [ + Array.from({ length: 6000 }, (_, i) => + rec(id(i), `n${i}`, i % 1000 === 0 ? { pic: 'f1' } : {}), + ), + [ + ...Array.from({ length: 6000 }, (_, i) => rec(id(6000 + i), `n${6000 + i}`)), + ...m.slice(0, 5).map((k) => rec(k, k)), + ], + ] + await push(h, user, pathA, { schemas: { Author } }, first, [], true) + await push(h, user, pathB, { schemas: { Author } }, first, [], false) + expect(await trees(h, a.id).then((t) => t.public)).toEqual( + await trees(h, b.id).then((t) => t.public), + ) + + // Push 2: a cluster of updates, scattered deletes, appends, a few records + // made private, new marks (one added and then deleted in the same session), + // and two base marks deleted. r03000–r09999 stays untouched: gaps. + // Separate batches, so no run block spans the untouched region. + const second = [ + [ + ...Array.from({ length: 300 }, (_, i) => + rec(id(1000 + i), `u${i}`, i % 50 === 1 ? { pic: 'f2' } : {}), + ), + ...m.slice(5).map((k) => rec(k, `${k}!`)), + ], + Array.from({ length: 50 }, (_, i) => + rec(id(10_000 + i), `p${i}`, { private: true, ...(i === 0 ? { pic: 'f3' } : {}) }), + ), + Array.from({ length: 2000 }, (_, i) => rec(`s${String(i).padStart(5, '0')}`, `s${i}`)), + ] + const deletes = [Array.from({ length: 40 }, (_, i) => id(10_500 + i * 13)), [m[3]!, m[6]!]] + // One interval per unit, so every deleted candidate is some unit's `through`. + const pa = await push(h, user, pathA, {}, second, deletes, { unitEntries: 1 }) + const pb = await push(h, user, pathB, {}, second, deletes, false) + expect(pa.result.changes).toEqual(pb.result.changes) + + const ta = await trees(h, a.id) + const tb = await trees(h, b.id) + expect(ta.public).toEqual(tb.public) + expect(ta.private).toEqual(tb.private) + expect(ta.private?.count).toBe(50) + expect(ta.version).toEqual(tb.version) + expect(ta.files).toEqual(tb.files) + // f1 is still public elsewhere, f2 entered the public set, f3 the private one. + expect(ta.files.public.count).toBe(2) + expect(ta.files.private?.count).toBe(1) + const v = await verifyTree(new RepoSource(recordTree, ta.repo), ta.public.root!) + expect(v.errors).toEqual([]) + + // The plan had gaps, several units, and merged the units whose split key was deleted. + const units = await h.ports.db + .select() + .from(schema.commitUnits) + .where(eq(schema.commitUnits.sessionId, pa.sid)) + // A gap inside the untouched r03000–r09999. + expect( + units.some((u) => u.gap && u.after !== null && u.after > id(3000) && u.after < id(9999)), + ).toBe(true) + expect(units.filter((u) => !u.gap && u.status === 'done').length).toBeGreaterThan(3) + expect(units.some((u) => u.status === 'superseded')).toBe(true) + }, 30_000) + + it('survives duplicate and early jobs', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('pd') + const path = '/api/collections/org/pd' + const open = await h.request(`${path}/push`, { + method: 'POST', + user, + json: { schemas: { Author } }, + }) + const sid = ((await open.json()) as { session_id: string }).session_id + const batch = [...markIds('d', 4), ...Array.from({ length: 3000 }, (_, i) => `x${i}`)] + await h.request(`${path}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: batch.map((k) => rec(k, k)), + }) + Object.assign(parallelConfig, { above: 0, unitEntries: 1, baseLevel: 0 }) + expect((await h.request(`${path}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 202, + ) + // An assembly before any unit ran, and duplicate deliveries of both jobs. + await assembleParallel(h.ports, sid) + await h.ports.jobs.enqueue({ type: 'push.commit', sessionId: sid }) + await h.ports.jobs.enqueue({ type: 'commit.assemble', sessionId: sid }) + await h.drain() + const [session] = await h.ports.db + .select() + .from(schema.pushSessions) + .where(eq(schema.pushSessions.id, sid)) + expect(session!.status).toBe('committed') + const versions = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, c.id)) + expect(versions.map((v) => v.recordCount)).toEqual([batch.length]) + }) + + it('reopens a session refused for missing files, and plans again on the next commit', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('pm') + const path = '/api/collections/org/pm' + const open = await h.request(`${path}/push`, { + method: 'POST', + user, + json: { schemas: { Author } }, + }) + const sid = ((await open.json()) as { session_id: string }).session_id + const batch = [...markIds('m', 4), ...Array.from({ length: 1000 }, (_, i) => `x${i}`)] + await h.request(`${path}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [...batch.map((k) => rec(k, k)), rec('withpic', 'P', { pic: 'portrait' })], + }) + Object.assign(parallelConfig, { above: 0, unitEntries: 1, baseLevel: 0 }) + const commit = async () => { + const res = await h.request(`${path}/push/${sid}/commit`, { method: 'POST', user }) + expect(res.status).toBe(202) + await h.drain() + return (await (await h.request(`${path}/push/${sid}`, { user })).json()) as Record< + string, + any + > + } + let status = await commit() + expect(status).toMatchObject({ + status: 'open', + error: { error: 'Missing files', filesNeeded: [sha('portrait')] }, + }) + const [s] = await h.ports.db + .select() + .from(schema.pushSessions) + .where(eq(schema.pushSessions.id, sid)) + expect(s!.commitPlan).toBeNull() + + // Upload the file, and one more record: the new plan covers it. + await h.request(`${path}/files/${sha('portrait')}`, { method: 'PUT', user, body: 'portrait' }) + await h.request(`${path}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [rec('late', 'L')], + }) + status = await commit() + expect(status).toMatchObject({ status: 'committed', error: null }) + const versions = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, c.id)) + expect(versions.map((v) => [v.recordCount, v.fileCount])).toEqual([[batch.length + 2, 1]]) + }) + + it('stays serial when a type changes schema', async () => { + const h = await harness() + const user = await h.member() + await h.collection('pc') + const path = '/api/collections/org/pc' + const batch = [Array.from({ length: 50 }, (_, i) => rec(`x${i}`, `n${i}`))] + await push(h, user, path, { schemas: { Author } }, batch, [], false) + const Author2 = { + ...Author, + properties: { name: { type: 'string' }, born: { type: 'integer' } }, + } + Object.assign(parallelConfig, { above: 0 }) + const res = await h.request(`${path}/push`, { + method: 'POST', + user, + json: { schemas: { Author: Author2 } }, + }) + const sid = ((await res.json()) as { session_id: string }).session_id + await h.request(`${path}/push/${sid}/records`, { method: 'POST', user, ndjson: batch[0]! }) + const commit = await h.request(`${path}/push/${sid}/commit`, { method: 'POST', user }) + expect(commit.status).toBe(201) + }) +}) + +// Keep the helper honest: marks are natural leaf boundaries. +it('marks are natural boundaries', () => { + for (const k of markIds('q', 3)) + expect(trailingZeros(boundaryBytes(k))).toBeGreaterThanOrEqual(13) +}) diff --git a/packages/server/test/perf.test.ts b/packages/server/test/perf.test.ts new file mode 100644 index 0000000..b585de2 --- /dev/null +++ b/packages/server/test/perf.test.ts @@ -0,0 +1,123 @@ +import { createHash } from 'node:crypto' +import { gunzipSync } from 'node:zlib' + +import { afterAll, describe, expect, it } from 'vitest' + +import { createApp } from '../src/app.js' +import * as schema from '../src/db/schema.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const sha = (s: string) => createHash('sha256').update(s).digest('hex') +const Doc = { type: 'object', properties: { title: { type: 'string' }, pdf: { type: 'object' } } } + +async function setup() { + const h = await harness() + const user = await h.member() + await h.collection('docs') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/docs' + const pdf = 'pdf' + await h.request(`${base}/files/${sha(pdf)}`, { method: 'PUT', user, body: pdf }) + const sid = ( + (await ( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Doc } } }) + ).json()) as { + session_id: string + } + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [ + ...Array.from({ length: 3000 }, (_, i) => ({ + id: `d${String(i).padStart(4, '0')}`, + type: 'Doc', + data: { title: `T${i}` }, + })), + // Over 64 KB: stored out of line, so its leaf is re-encoded. + { + id: 'big', + type: 'Doc', + data: { title: 'x'.repeat(70_000), pdf: { $file: `sha256:${sha(pdf)}` } }, + }, + ], + }) + await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + return { h, user, base, pdf } +} + +describe('page performance', () => { + it('serves a type as concatenated gzip bodies, matching records.ndjson', async () => { + const { h, base } = await setup() + const ndjson = ( + await (await h.request(`${base}/versions/latest/records.ndjson?type=Doc`)).text() + ) + .split('\n') + .filter(Boolean) + .map((l) => { + const { hash: _, ...rest } = JSON.parse(l) as Record + return JSON.stringify(rest) + }) + const res = await h.request(`${base}/versions/latest/records.ndjson.gz?type=Doc`) + expect(res.headers.get('content-type')).toBe('application/gzip') + const lines = gunzipSync(Buffer.from(await res.arrayBuffer())) + .toString('utf8') + .split('\n') + .filter(Boolean) + expect(lines).toHaveLength(3001) + expect(lines).toEqual(ndjson) + // A blocked record is left out. + const blocked = sha(lines[5]!) + await h.ports.db + .insert(schema.denylist) + .values({ hash: blocked, kind: 'record', reason: 'test' }) + const { forgetDenylist } = await import('../src/lib/limits.js') + forgetDenylist(h.ports.db) + const again = gunzipSync( + Buffer.from( + await (await h.request(`${base}/versions/latest/records.ndjson.gz`)).arrayBuffer(), + ), + ) + .toString('utf8') + .split('\n') + .filter(Boolean) + expect(again).toHaveLength(3000) + expect(again).not.toContain(lines[5]) + expect((await h.request(`${base}/versions/latest/records.ndjson.gz?type=Nope`)).status).toBe( + 404, + ) + }) + + it('lets a published version be cached, but not latest', async () => { + const { h, user, base, pdf } = await setup() + const cc = async (path: string, u?: string) => + (await h.request(path, u ? { user: u } : {})).headers.get('cache-control') + expect(await cc(`${base}/versions/v1.0.0/records?type=Doc`)).toMatch(/^public, max-age=600/) + expect(await cc(`${base}/versions/v1.0.0`)).toMatch(/^public/) + expect(await cc(`${base}/versions/v1.0.0/records?type=Doc`, user)).toBe('private, max-age=3600') + expect(await cc(`${base}/versions/latest/records?type=Doc`)).toBeNull() + expect(await cc(`${base}/versions/v9.9.9`)).toBeNull() + expect(await cc(`${base}/files/${sha(pdf)}`)).toBe('private, max-age=240') + }) + + it('authenticates a page once for all its in-process API calls', async () => { + const h = await harness() + let calls = 0 + const app = createApp(() => ({ + ports: h.ports, + config: { appUrl: 'http://test', deployment: 'test' }, + authenticate: async () => { + calls++ + return null + }, + renderPage: async (req, api) => { + for (let i = 0; i < 3; i++) await api(new Request('http://test/api/health', req)) + return new Response('page') + }, + })) + await app.fetch(new Request('http://test/some/page')) + expect(calls).toBe(1) + }) +}) diff --git a/packages/server/test/push.test.ts b/packages/server/test/push.test.ts new file mode 100644 index 0000000..186abe8 --- /dev/null +++ b/packages/server/test/push.test.ts @@ -0,0 +1,641 @@ +import { createHash } from 'node:crypto' + +import { getEntry, hashRecord, recordTree, RepoSource } from '@underlay/protocol' +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import { commitConfig } from '../src/api/push.js' +import * as schema from '../src/db/schema.js' +import { finalizeSession } from '../src/push/finalize.js' +import { limits } from '../src/push/session.js' +import { cleanup, type Harness, harness } from './harness.js' + +afterAll(cleanup) + +const Author = { + type: 'object', + properties: { name: { type: 'string' }, born: { type: 'integer' } }, + required: ['name'], +} + +const rec = (id: string, data: Record, extra: Record = {}) => ({ + id, + type: 'Author', + data, + ...extra, +}) +const hashOf = (id: string, data: unknown) => hashRecord(id, 'Author', data).hash +const sha = (b: string) => createHash('sha256').update(b).digest('hex') + +async function setup() { + const h = await harness() + const user = await h.member() + const c = await h.collection('authors') + return { h, user, c, base: '/api/collections/org/authors' } +} + +async function json(res: Response) { + return (await res.json()) as Record +} + +async function head(h: Harness, collectionId: string) { + const [c] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, collectionId)) + if (!c?.headVersionId) return null + const [v] = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, c.headVersionId)) + return v! +} + +describe('delta push', () => { + it('parses a records batch as it streams in, across any chunk boundaries', async () => { + const { h, user, base, c } = await setup() + const open = await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Author } }, + }) + const sid = ((await open.json()) as { session_id: string }).session_id + const body = new TextEncoder().encode( + [rec('a', { name: 'Zoë' }), rec('b', { name: '日本語' }), rec('c', { name: 'C' })] + .map((r) => JSON.stringify(r)) + .join('\n'), // no trailing newline + ) + // Three-byte chunks: lines and multi-byte characters split everywhere. + const chunked = (bytes: Uint8Array, size: number) => + new ReadableStream({ + start(ctl) { + for (let i = 0; i < bytes.length; i += size) ctl.enqueue(bytes.slice(i, i + size)) + ctl.close() + }, + }) + const send = (stream: ReadableStream) => + h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + body: stream, + headers: { 'content-type': 'application/x-ndjson' }, + duplex: 'half', + } as RequestInit) + const res = await send(chunked(body, 3)) + expect(await res.json()).toEqual({ received: 3 }) + await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + const repo = await h.ports.stores.forCollection(c.id) + const v = await head(h, c.id) + const root = await repo.root(v!.hash) + const got = await getEntry( + new RepoSource(recordTree, repo), + root.public.types.Author!.root, + 'b', + ) + expect(got?.hash).toBe(hashOf('b', { name: '日本語' })) + + // Past the cap, with no content-length to refuse it early: 413 mid-stream. + const open2 = await h.request(`${base}/push`, { method: 'POST', user, json: {} }) + const sid2 = ((await open2.json()) as { session_id: string }).session_id + const big = new ReadableStream({ + pull(ctl) { + ctl.enqueue( + new TextEncoder().encode(JSON.stringify(rec('x', { name: 'x'.repeat(1 << 20) })) + '\n'), + ) + }, + }) + const tooBig = await h.request(`${base}/push/${sid2}/records`, { + method: 'POST', + user, + body: big, + duplex: 'half', + } as RequestInit) + expect(tooBig.status).toBe(413) + }) + + it('commits a small push that changes a large type’s schema as a job', async () => { + const { h, user, base } = await setup() + const push = async (open: object, records: object[]) => { + const sid = ( + await json(await h.request(`${base}/push`, { method: 'POST', user, json: open })) + ).session_id + await h.request(`${base}/push/${sid}/records`, { method: 'POST', user, ndjson: records }) + return h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + } + const names = ['a', 'b', 'c', 'd', 'e'].map((n) => rec(n, { name: n })) + expect((await push({ schemas: { Author } }, names)).status).toBe(201) + commitConfig.asyncAbove = 3 + try { + // One record, the same schema: in the request. + expect((await push({}, [rec('f', { name: 'f' })])).status).toBe(201) + // One record, but the schema changes for a type of six: a job. + const looser = { ...Author, required: [] } + const res = await push({ schemas: { Author: looser } }, [rec('g', { name: 'g' })]) + expect(res.status).toBe(202) + await h.drain() + } finally { + commitConfig.asyncAbove = 100_000 + } + }) + + it('caps the sessions one user has in progress', async () => { + const { h, user, base } = await setup() + const before = limits.openSessions + limits.openSessions = 2 + try { + const open = () => + h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Author } } }) + const a = ((await (await open()).json()) as { session_id: string }).session_id + expect((await open()).status).toBe(200) + const refused = await open() + expect(refused.status).toBe(429) + expect(((await refused.json()) as { error: string }).error).toMatch(/2 push sessions/) + // Aborting one makes room; another user is unaffected. + expect( + (await h.request(`${base}/push/${a}`, { method: 'DELETE', user })).status, + ).toBeLessThan(300) + expect((await open()).status).toBe(200) + const other = await h.member('u2') + expect( + ( + await h.request(`${base}/push`, { + method: 'POST', + user: other, + json: { schemas: { Author } }, + }) + ).status, + ).toBe(200) + } finally { + limits.openSessions = before + } + }) + + it('opens, uploads, deletes and commits', async () => { + const { h, user, c, base } = await setup() + let res = await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Author }, metadata: { title: 'Authors' } }, + }) + expect(res.status).toBe(200) + const opened = await json(res) + // The node advertises its limits (protocol section 11.4). + expect(opened.limits).toEqual({ + open_bytes: 8 * 1024 * 1024, + batch_bytes: 16 * 1024 * 1024, + batch_lines: 10_000, + session_idle_seconds: 3600, + open_sessions: 20, + file_bytes: 32 * 1024 * 1024, + }) + let sid = opened.session_id + res = await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [ + rec('ada', { name: 'Ada', born: 1815 }), + rec('alan', { name: 'Alan' }), + rec('kurt', { name: 'Kurt' }, { private: true }), + ], + }) + expect(await json(res)).toEqual({ received: 3 }) + res = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(res.status).toBe(201) + const v1 = await json(res) + expect(v1).toMatchObject({ semver: 'v1.0.0', recordCount: 3 }) + + // Second push: base required to match; update, delete, flip one to public. + res = await h.request(`${base}/push`, { method: 'POST', user, json: { base: 'v0.9.0' } }) + expect(res.status).toBe(409) + res = await h.request(`${base}/push`, { method: 'POST', user, json: { base: 'v1.0.0' } }) + sid = (await json(res)).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [rec('ada', { name: 'Ada Lovelace', born: 1815 }), rec('kurt', { name: 'Kurt' })], + }) + await h.request(`${base}/push/${sid}/deletes`, { + method: 'POST', + user, + ndjson: [{ type: 'Author', id: 'alan' }], + }) + res = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(res.status).toBe(201) + const v2 = await head(h, c.id) + expect(v2).toMatchObject({ + semver: 'v1.1.0', + recordCount: 2, + publicRecordCount: 2, + hasPrivate: false, + }) + const repo = await h.ports.stores.forCollection(c.id) + const root = await repo.root(v2!.hash) + // Metadata was kept from the base. + expect(root.metadata).toEqual({ title: 'Authors' }) + const kurt = await getEntry( + new RepoSource(recordTree, repo), + root.public.types.Author!.root, + 'kurt', + ) + expect(kurt?.hash).toBe(hashOf('kurt', { name: 'Kurt' })) + }) + + it('compacts runs during upload, later uploads still winning', async () => { + const { h, user, c, base } = await setup() + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Author } } }), + ) + ).session_id + // 40 uploads over 10 ids: each id is rewritten, and a few are deleted then re-added. + for (let i = 0; i < 40; i++) { + const id = `a${i % 10}` + const path = i % 7 === 3 ? 'deletes' : 'records' + const res = await h.request(`${base}/push/${sid}/${path}`, { + method: 'POST', + user, + ndjson: [path === 'deletes' ? { type: 'Author', id } : rec(id, { name: `n${i}` })], + }) + expect(res.status).toBe(200) + await h.drain() + } + const runs = await h.ports.db + .select() + .from(schema.pushRuns) + .where(eq(schema.pushRuns.sessionId, sid)) + expect(runs.some((r) => r.tier === 1)).toBe(true) + expect(runs.length).toBeLessThan(40) + expect(runs.every((r) => r.mergingInto === null)).toBe(true) + const res = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(res.status).toBe(201) + // The last write to each id wins: a delete for i % 7 === 3, else the record. + const last = new Map() + for (let i = 0; i < 40; i++) last.set(`a${i % 10}`, i) + const repo = await h.ports.stores.forCollection(c.id) + const root = await repo.root((await head(h, c.id))!.hash) + const source = new RepoSource(recordTree, repo) + for (const [id, i] of last) { + const e = await getEntry(source, root.public.types.Author!.root, id) + expect(e?.hash ?? null).toBe(i % 7 === 3 ? null : hashOf(id, { name: `n${i}` })) + } + }) + + it('reports invalid records by line, and the input rules', async () => { + const { h, user, base } = await setup() + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Author } } }), + ) + ).session_id + const res = await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + body: [ + JSON.stringify(rec('ok', { name: 'Fine' })), + JSON.stringify(rec('bad', { born: 'x' })), + '{"id":"dup","type":"Author","data":{"name":"a","name":"b"}}', + '{"id":"big","type":"Author","data":{"name":"n","born":12345678901234567890}}', + JSON.stringify(rec('extra', { name: 'E', nickname: 'e' })), + // Not a schema property, whatever JavaScript objects inherit. + JSON.stringify(rec('proto', { name: 'P', toString: 'x' })), + ].join('\n'), + }) + expect(res.status).toBe(422) + const body = await json(res) + expect(body.totalErrors).toBe(5) + expect(body.validationErrors.map((e: { line: number }) => e.line)).toEqual([2, 3, 4, 5, 6]) + expect(body.validationErrors[1].errors[0]).toMatch(/duplicate_key/) + expect(body.validationErrors[2].errors[0]).toMatch(/unsafe_integer/) + }) + + it('commits asynchronously as a job', async () => { + const { h, user, base } = await setup() + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Author } } }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [rec('a', { name: 'A' })], + }) + const res = await h.request(`${base}/push/${sid}/commit?async=true`, { method: 'POST', user }) + expect(res.status).toBe(202) + expect((await json(await h.request(`${base}/push/${sid}`, { user }))).status).toBe('committing') + await h.drain() + const status = await json(await h.request(`${base}/push/${sid}`, { user })) + expect(status).toMatchObject({ status: 'committed', result: { semver: 'v1.0.0' } }) + }) + + it('keeps writes to members, and private collections hidden', async () => { + const { h, base } = await setup() + expect((await h.request(`${base}/push`, { method: 'POST', json: {} })).status).toBe(404) + await h.ports.db.update(schema.collections).set({ public: true }) + expect((await h.request(`${base}/push`, { method: 'POST', json: {} })).status).toBe(401) + expect( + (await h.request(`${base}/push`, { method: 'POST', user: 'stranger', json: {} })).status, + ).toBe(403) + }) + + it('refuses a schema that does not compile at open, upload and commit', async () => { + const { h, user, c, base } = await setup() + const open = (json: object) => h.request(`${base}/push`, { method: 'POST', user, json }) + // Not draft-07, a regex the u flag rejects, a $ref that doesn't resolve. + for (const Bad of [ + { type: 'text' }, + { type: 'object', properties: { s: { type: 'string', pattern: '\\-' } } }, + { type: 'object', properties: { s: { $ref: '#/definitions/nope' } } }, + ]) { + const res = await open({ schemas: { Author, Bad } }) + expect(res.status).toBe(422) + expect((await json(res)).error).toMatch(/"Bad"/) + } + + // A session opened before the check (its inputs written directly): uploads + // of the bad type fail by line with a 422, and the commit publishes nothing. + const sid = (await json(await open({ schemas: { Author } }))).session_id + await h.ports.stores.internal.put( + `sessions/${sid}/inputs.json`, + JSON.stringify({ + schemas: { Author, Bad: { type: 'text' } }, + metadata: null, + files: { add: [], remove: [] }, + }), + ) + let res = await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [rec('a', { name: 'A' }), { id: 'b1', type: 'Bad', data: {} }], + }) + expect(res.status).toBe(422) + const body = await json(res) + expect(body.totalErrors).toBe(1) + expect(body.validationErrors[0]).toMatchObject({ line: 2, type: 'Bad' }) + expect(body.validationErrors[0].errors[0]).toMatch(/refused/) + res = await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [rec('a', { name: 'A' })], + }) + expect(res.status).toBe(200) + res = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(res.status).toBe(422) + expect((await json(res)).error).toMatch(/"Bad"/) + expect(await head(h, c.id)).toBeNull() + }) + + it('takes metadata as an object or null, and metadata_patch as an object', async () => { + const { h, user, base } = await setup() + const open = (json: object) => h.request(`${base}/push`, { method: 'POST', user, json }) + for (const metadata of [['a'], 'x', 3, true]) { + const res = await open({ schemas: { Author }, metadata }) + expect(res.status).toBe(400) + expect((await json(res)).error).toBe('"metadata" must be an object or null') + } + for (const metadata_patch of [['a'], null, 'x', 3]) { + const res = await open({ schemas: { Author }, metadata_patch }) + expect(res.status).toBe(400) + expect((await json(res)).error).toBe('"metadata_patch" must be an object') + } + expect((await open({ schemas: { Author }, metadata: null })).status).toBe(200) + expect((await open({ schemas: { Author }, metadata_patch: { a: 1 } })).status).toBe(200) + }) + + it('applies the input rules to deletes, and reports them like records', async () => { + const { h, user, base } = await setup() + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Author } } }), + ) + ).session_id + const send = (body: string) => + h.request(`${base}/push/${sid}/deletes`, { method: 'POST', user, body }) + let res = await send( + [ + '{"type":"Author","id":"ok"}', + 'not json', + '{"type":"Author","id":"a","id":"b"}', + '{"type":"Author","id":""}', + '{"type":"a/b","id":"x"}', + '["Author","x"]', + '{"type":"Other","id":"x"}', + '', + `{"type":"Author","id":"${'x'.repeat(1025)}"}`, + ].join('\n'), + ) + expect(res.status).toBe(422) + let body = await json(res) + expect(body.error).toBe('Invalid deletes') + expect(body.errors).toBeUndefined() + expect(body.totalErrors).toBe(7) + // Lines count among the non-empty ones. + expect(body.validationErrors.map((e: { line: number }) => e.line)).toEqual([ + 2, 3, 4, 5, 6, 7, 8, + ]) + const first = body.validationErrors.map((e: { errors: string[] }) => e.errors[0]) + expect(first[0]).toMatch(/^syntax:/) + expect(first[1]).toMatch(/^duplicate_key:/) + expect(first[2]).toMatch(/^bad_id:/) + expect(first[3]).toMatch(/^bad_type:/) + expect(first[4]).toMatch(/^bad_envelope:/) + expect(first[5]).toBe('No schema for type "Other"') + expect(first[6]).toMatch(/^bad_id:/) + + // At most the first 100 failures are listed; totalErrors counts them all. + res = await send(Array.from({ length: 150 }, () => 'nope').join('\n')) + body = await json(res) + expect(body.validationErrors).toHaveLength(100) + expect(body.totalErrors).toBe(150) + + res = await send('{"type":"Author","id":"ok"}') + expect(await json(res)).toEqual({ received: 1 }) + }) + + it('lists as needed only the files a commit would refuse, as bare hex', async () => { + const { h, user, base } = await setup() + const uploaded = 'uploaded to this collection' + const missing = 'never uploaded' + expect( + (await h.request(`${base}/files/${sha(uploaded)}`, { method: 'PUT', user, body: uploaded })) + .status, + ).toBe(201) + const open = async (add: string[]) => + json( + await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Author }, files: { add } }, + }), + ) + let opened = await open([sha(uploaded), sha(missing), sha(missing)]) + // The upload (a verified file_uploads row) counts, as it does at commit. + expect(opened.needed_files).toEqual([sha(missing)]) + let res = await h.request(`${base}/push/${opened.session_id}/commit`, { + method: 'POST', + user, + }) + expect(res.status).toBe(422) + expect(await json(res)).toMatchObject({ error: 'Missing files', filesNeeded: [sha(missing)] }) + + opened = await open([sha(uploaded)]) + expect(opened.needed_files).toEqual([]) + res = await h.request(`${base}/push/${opened.session_id}/commit`, { method: 'POST', user }) + expect(res.status).toBe(201) + }) + + it('answers a commit whose base moved with the current version', async () => { + const { h, user, base } = await setup() + const open = async () => + ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Author } } }), + ) + ).session_id + const [first, second] = [await open(), await open()] + for (const sid of [first, second]) { + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [rec(sid, { name: 'N' })], + }) + } + expect((await h.request(`${base}/push/${first}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + const res = await h.request(`${base}/push/${second}/commit`, { method: 'POST', user }) + expect(res.status).toBe(409) + expect(await json(res)).toMatchObject({ error: 'Version conflict', currentVersion: 'v1.0.0' }) + }) +}) + +describe('push sessions settle once', () => { + /** A session with one record, moved to committing as the commit route does. */ + async function committing(h: Harness, user: string, base: string) { + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Author } } }), + ) + ).session_id as string + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [rec('a', { name: 'A' })], + }) + await h.ports.db + .update(schema.pushSessions) + .set({ status: 'committing' }) + .where(eq(schema.pushSessions.id, sid)) + return sid + } + + async function settled(h: Harness, collectionId: string, sid: string) { + const versions = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, collectionId)) + const [s] = await h.ports.db + .select() + .from(schema.pushSessions) + .where(eq(schema.pushSessions.id, sid)) + return { versions, session: s! } + } + + it('commits once when finalize runs twice in a row, even if the first never settled', async () => { + const { h, user, base, c } = await setup() + const sid = await committing(h, user, base) + const first = await finalizeSession(h.ports, sid) + expect(first.status).toBe(201) + // A job delivered again after the first settled. + expect(await finalizeSession(h.ports, sid)).toEqual(first) + // A first run that published but died before settling: the retry finds + // the head moved by its own version. + await h.ports.db + .update(schema.pushSessions) + .set({ status: 'committing', result: null }) + .where(eq(schema.pushSessions.id, sid)) + expect(await finalizeSession(h.ports, sid)).toEqual(first) + const { versions, session } = await settled(h, c.id, sid) + expect(versions).toHaveLength(1) + expect(versions[0]!.pushSessionId).toBe(sid) + expect(session).toMatchObject({ status: 'committed', error: null, result: first.body }) + expect(first.body).toMatchObject({ semver: versions[0]!.semver, hash: versions[0]!.hash }) + }) + + it('commits once when finalize runs twice at the same time', async () => { + const { h, user, base, c } = await setup() + const sid = await committing(h, user, base) + const [a, b] = await Promise.all([finalizeSession(h.ports, sid), finalizeSession(h.ports, sid)]) + expect(a.status).toBe(201) + expect(b).toEqual(a) + const { versions, session } = await settled(h, c.id, sid) + expect(versions).toHaveLength(1) + expect(session).toMatchObject({ status: 'committed', error: null, result: a.body }) + }) + + it('never commits a session that is open', async () => { + const { h, user, base, c } = await setup() + const sid = await committing(h, user, base) + await h.ports.db + .update(schema.pushSessions) + .set({ status: 'open' }) + .where(eq(schema.pushSessions.id, sid)) + expect((await finalizeSession(h.ports, sid)).status).toBe(409) + expect((await settled(h, c.id, sid)).versions).toHaveLength(0) + }) + + for (const mode of ['sync', 'async'] as const) { + it(`keeps a session refused for missing files open, to commit again (${mode})`, async () => { + const { h, user, base, c } = await setup() + const content = 'uploaded late' + const opened = await json( + await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Author }, files: { add: [sha(content)] } }, + }), + ) + const sid = opened.session_id as string + expect(opened.needed_files).toEqual([sha(content)]) + const commit = async () => { + const res = await h.request( + `${base}/push/${sid}/commit${mode === 'async' ? '?async=true' : ''}`, + { method: 'POST', user }, + ) + if (mode === 'sync') return res + expect(res.status).toBe(202) + await h.drain() + return null + } + const status = async () => json(await h.request(`${base}/push/${sid}`, { user })) + + const refused = await commit() + if (refused) { + expect(refused.status).toBe(422) + expect(await json(refused)).toMatchObject({ error: 'Missing files' }) + } + // Open, with the error saying what to upload. + const s = await status() + expect(s).toMatchObject({ + status: 'open', + error: { error: 'Missing files', filesNeeded: [sha(content)], statusCode: 422 }, + result: null, + finalize_started_at: null, + }) + expect(new Date(s.expires_at).getTime()).toBeGreaterThan(Date.now()) + + expect( + (await h.request(`${base}/files/${sha(content)}`, { method: 'PUT', user, body: content })) + .status, + ).toBe(201) + const done = await commit() + if (done) expect(done.status).toBe(201) + expect(await status()).toMatchObject({ + status: 'committed', + error: null, + result: { semver: 'v1.0.0', fileCount: 1 }, + }) + expect((await settled(h, c.id, sid)).versions).toHaveLength(1) + }) + } +}) diff --git a/packages/server/test/read-features.test.ts b/packages/server/test/read-features.test.ts new file mode 100644 index 0000000..73d2537 --- /dev/null +++ b/packages/server/test/read-features.test.ts @@ -0,0 +1,97 @@ +import { hashRecord, hashSchema } from '@underlay/protocol' +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { cleanup, type Harness, harness } from './harness.js' + +afterAll(cleanup) + +const Doc = { type: 'object', properties: { title: { type: 'string' } } } +const doc = (id: string, title: string) => ({ id, type: 'Doc', data: { title } }) + +async function push( + h: Harness, + user: string, + base: string, + open: object, + records: object[], + deletes: object[] = [], +) { + const sid = ( + (await (await h.request(`${base}/push`, { method: 'POST', user, json: open })).json()) as { + session_id: string + } + ).session_id + if (records.length) + await h.request(`${base}/push/${sid}/records`, { method: 'POST', user, ndjson: records }) + if (deletes.length) + await h.request(`${base}/push/${sid}/deletes`, { method: 'POST', user, ndjson: deletes }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe(201) + await h.drain() +} + +describe('read features', () => { + it('reads a record by type and id, its history, and where it first appeared', async () => { + const h = await harness() + const user = await h.member() + await h.collection('docs') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/docs' + await push(h, user, base, { schemas: { Doc } }, [doc('a', 'A'), doc('b', 'B')]) + await push(h, user, base, {}, [doc('a', 'A2')]) + await push(h, user, base, {}, [], [{ type: 'Doc', id: 'b' }]) + await push(h, user, base, {}, [doc('b', 'B')]) + + const one = await (await h.request(`${base}/versions/v1.0.0/records/Doc/a`)).json() + expect(one).toMatchObject({ id: 'a', type: 'Doc', data: { title: 'A' }, semver: 'v1.0.0' }) + expect((await h.request(`${base}/versions/latest/records/Doc/zzz`)).status).toBe(404) + + const history = async (id: string) => + ( + (await (await h.request(`${base}/records/Doc/${id}/history`)).json()) as { + changes: { semver: string; change: string }[] + } + ).changes.map((x) => `${x.semver}:${x.change}`) + const semvers = ( + (await (await h.request(`${base}/versions`)).json()) as { semver: string; seq?: number }[] + ) + .map((v) => v.semver) + .reverse() + expect(await history('a')).toEqual([`${semvers[0]}:added`, `${semvers[1]}:updated`]) + expect(await history('b')).toEqual([ + `${semvers[0]}:added`, + `${semvers[2]}:removed`, + `${semvers[3]}:added`, + ]) + expect((await h.request(`${base}/records/Doc/nope/history`)).status).toBe(404) + + const hashB = hashRecord('b', 'Doc', { title: 'B' }).hash + const first = await (await h.request(`/api/records/${hashB}/first`)).json() + expect(first).toMatchObject({ + kind: 'record', + owner: 'org', + collection: 'docs', + semver: semvers[0], + id: 'b', + }) + + // Private: a stranger finds nothing; a member does. + await h.ports.db.update(schema.collections).set({ public: false }) + expect((await h.request(`/api/records/${hashB}/first`)).status).toBe(404) + expect((await h.request(`/api/records/${hashB}/first`, { user })).status).toBe(200) + }) + + it('shows members where their private collections use a schema', async () => { + const h = await harness() + const user = await h.member() + await h.collection('secret') + const base = '/api/collections/org/secret' + await push(h, user, base, { schemas: { Doc } }, [doc('a', 'A')]) + const id = hashSchema(Doc) + const mine = (await (await h.request(`/api/schemas/${id}`, { user })).json()) as { + usage: { collection: string }[] + } + expect(mine.usage.map((u) => u.collection)).toEqual(['org/secret']) + expect((await h.request(`/api/schemas/${id}`)).status).toBe(404) + }) +}) diff --git a/packages/server/test/read.test.ts b/packages/server/test/read.test.ts new file mode 100644 index 0000000..ac82a47 --- /dev/null +++ b/packages/server/test/read.test.ts @@ -0,0 +1,336 @@ +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { cleanup, type Harness, harness } from './harness.js' + +afterAll(cleanup) + +const Author = { type: 'object', properties: { name: { type: 'string' } } } +const Book = { type: 'object', properties: { title: { type: 'string' } } } +const Secret = { type: 'object', private: true, properties: { note: { type: 'string' } } } + +async function json(res: Response) { + expect(res.headers.get('content-type') ?? '').toMatch(/json/) + return (await res.json()) as any +} + +async function pushDelta( + h: Harness, + user: string, + base: string, + body: object, + records: object[], + deletes: object[] = [], +) { + const sid = (await json(await h.request(`${base}/push`, { method: 'POST', user, json: body }))) + .session_id + for (let i = 0; i < records.length; i += 5000) { + const r = await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: records.slice(i, i + 5000), + }) + expect(r.status).toBe(200) + } + if (deletes.length) + await h.request(`${base}/push/${sid}/deletes`, { method: 'POST', user, ndjson: deletes }) + const res = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(res.status).toBe(201) + return json(res) +} + +async function setup() { + const h = await harness() + const user = await h.member() + const c = await h.collection('lib') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/lib' + const authors = Array.from({ length: 1200 }, (_, i) => ({ + id: `a${String(i).padStart(4, '0')}`, + type: 'Author', + data: { name: `Author ${i}` }, + ...(i % 10 === 0 ? { private: true } : {}), + })) + const books = Array.from({ length: 300 }, (_, i) => ({ + id: `b${i}`, + type: 'Book', + data: { title: `Book ${i}` }, + })) + const secrets = [{ id: 's1', type: 'Secret', data: { note: 'hidden' } }] + await pushDelta( + h, + user, + base, + { + schemas: { Author, Book, Secret }, + metadata: { title: 'Library', description: 'Books', tags: ['lit'] }, + }, + [...authors, ...books, ...secrets], + ) + return { h, user, c, base } +} + +describe('read API', () => { + it('serves collections and versions with owner and public views', async () => { + const { h, user, base } = await setup() + const anon = await json(await h.request(base)) + expect(anon).toMatchObject({ + slug: 'lib', + ownerSlug: 'org', + description: 'Books', + versionCount: 1, + }) + expect(anon.latestVersion).toMatchObject({ + semver: 'v1.0.0', + recordCount: 1380, + metadata: { title: 'Library' }, + }) + expect(anon.latestVersion.typeCounts).toEqual([ + { type: 'Author', count: 1080 }, + { type: 'Book', count: 300 }, + ]) + const owner = await json(await h.request(base, { user })) + expect(owner.latestVersion.recordCount).toBe(1501) + + const list = await json(await h.request('/api/collections')) + expect(list.collections[0]).toMatchObject({ + slug: 'lib', + tags: ['lit'], + latestVersion: 'v1.0.0', + recordCount: 1380, + }) + expect(list.facets.tags).toEqual([{ name: 'lit', count: 1 }]) + + const v = await json(await h.request(`${base}/versions/v1.0.0`)) + expect(Object.keys(v.schemas)).toEqual(['Author', 'Book']) + expect(v.typeCounts).toEqual({ Author: 1080, Book: 300 }) + const vOwner = await json(await h.request(`${base}/versions/latest`, { user })) + expect(Object.keys(vOwner.schemas)).toEqual(['Author', 'Book', 'Secret']) + expect((await json(await h.request(`${base}/versions`)))[0]).toMatchObject({ semver: 'v1.0.0' }) + }) + + it('serves a records page in the version call, with only its type’s schema', async () => { + const { h, user, base } = await setup() + const first = await json(await h.request(`${base}/versions/latest?records=&limit=10`, { user })) + expect(Object.keys(first.schemas)).toEqual(['Author']) + expect(Object.keys(first.typeCounts).sort()).toEqual(['Author', 'Book', 'Secret']) + expect(first.recordsPage).toMatchObject({ type: 'Author', total: 1200 }) + expect(first.recordsPage.records).toHaveLength(10) + const books = await json( + await h.request(`${base}/versions/latest?records=Book&offset=295&limit=10`), + ) + expect(Object.keys(books.schemas)).toEqual(['Book']) + expect(books.recordsPage.records).toHaveLength(5) + // Without ?records, every schema as before, and no page. + const plain = await json(await h.request(`${base}/versions/latest`)) + expect(Object.keys(plain.schemas).sort()).toEqual(['Author', 'Book']) + expect(plain.recordsPage).toBeUndefined() + }) + + it('pages records by offset and by cursor, within and across types', async () => { + const { h, user, base } = await setup() + // Offset deep in a type (v1 refused offsets past 10k; here it's a seek). + let r = await json( + await h.request(`${base}/versions/latest/records?type=Author&offset=1000&limit=5`), + ) + expect(r.records.map((x: any) => x.id)).toEqual(['a1112', 'a1113', 'a1114', 'a1115', 'a1116']) + expect(r.pagination.total).toBe(1080) + r = await json( + await h.request(`${base}/versions/latest/records?type=Author&offset=1000&limit=5`, { user }), + ) + expect(r.records.map((x: any) => x.id)).toEqual(['a1000', 'a1001', 'a1002', 'a1003', 'a1004']) + + // Cursor paging across types visits every visible record once, in (type, id) order. + const seen: string[] = [] + let cursor: string | null = null + do { + const page: any = await json( + await h.request( + `${base}/versions/latest/records?limit=400${cursor ? `&cursor=${cursor}` : ''}`, + ), + ) + seen.push(...page.records.map((x: any) => `${x.type}/${x.id}`)) + cursor = page.pagination.nextCursor + } while (cursor) + expect(seen.length).toBe(1380) + expect(new Set(seen).size).toBe(1380) + expect(seen.some((s) => s.startsWith('Secret/'))).toBe(false) + // An offset across types lands in the second type. + r = await json(await h.request(`${base}/versions/latest/records?offset=1081&limit=1`)) + expect(r.records[0]).toMatchObject({ type: 'Book', id: 'b1' }) + expect(r.records[0].hash).toMatch(/^[0-9a-f]{64}$/) + + // Members' private records say so; public ones don't carry the field. + r = await json(await h.request(`${base}/versions/latest/records?type=Author&limit=2`, { user })) + expect(r.records.map((x: any) => [x.id, x.private])).toEqual([ + ['a0000', true], + ['a0001', undefined], + ]) + }) + + it('streams NDJSON, and serves manifests (full and delta) and diffs', async () => { + const { h, user, base } = await setup() + const res = await h.request(`${base}/versions/latest/records.ndjson?type=Book`) + expect(res.headers.get('x-underlay-record-count')).toBe('300') + const lines = (await res.text()) + .trim() + .split('\n') + .map((l) => JSON.parse(l)) + expect(lines.length).toBe(300) + expect(lines[0]).toMatchObject({ id: 'b0', type: 'Book', data: { title: 'Book 0' } }) + + const pages: any[] = [] + let cursor: string | null = null + do { + const m: any = await json( + await h.request( + `${base}/versions/latest/manifest?limit=500${cursor ? `&cursor=${cursor}` : ''}`, + { user }, + ), + ) + pages.push(m) + cursor = m.pagination.nextCursor + } while (cursor) + const entries = pages.flatMap((p) => p.records) + expect(entries.length).toBe(1501) + expect(entries.filter((e) => e.private).length).toBe(121) + + // Second version: one update, one delete, one add. + await pushDelta( + h, + user, + base, + { base: 'v1.0.0' }, + [ + { id: 'b0', type: 'Book', data: { title: 'Changed' } }, + { id: 'b999', type: 'Book', data: { title: 'New' } }, + ], + [{ type: 'Book', id: 'b1' }], + ) + const delta = await json(await h.request(`${base}/versions/v1.1.0/manifest?since=v1.0.0`)) + expect(delta.delta.updated.map((x: any) => x.id)).toEqual(['b0']) + expect(delta.delta.removed.map((x: any) => x.id)).toEqual(['b1']) + expect(delta.delta.added.map((x: any) => x.id)).toEqual(['b999']) + const diff = await json(await h.request(`${base}/versions/v1.1.0/diff?from=v1.0.0`)) + expect(diff).toMatchObject({ + from: 'v1.0.0', + to: 'v1.1.0', + removed: [{ id: 'b1', type: 'Book' }], + meta: { schemaChanged: false }, + }) + expect(diff.updated[0]).toMatchObject({ id: 'b0', data: { title: 'Changed' } }) + // Without ?from=, a version is compared with the one before it; the first with nothing. + expect(await json(await h.request(`${base}/versions/v1.1.0/diff`))).toEqual(diff) + const first = await json(await h.request(`${base}/versions/v1.0.0/diff?limit=5000`)) + expect(first).toMatchObject({ from: null, to: 'v1.0.0', updated: [], removed: [] }) + expect(first.added).toHaveLength(1380) + // Diff paging resumes without skipping or repeating. + const p1 = await json(await h.request(`${base}/versions/v1.1.0/diff?from=v1.0.0&limit=2`)) + const p2 = await json( + await h.request( + `${base}/versions/v1.1.0/diff?from=v1.0.0&limit=2&cursor=${p1.pagination.nextCursor}`, + ), + ) + const ids = [ + ...p1.added, + ...p1.updated, + ...p1.removed, + ...p2.added, + ...p2.updated, + ...p2.removed, + ].map((x: any) => x.id) + expect(ids.sort()).toEqual(['b0', 'b1', 'b999']) + }) + + it('lists moves between sets in a manifest delta, by who may read which set', async () => { + const { h, user, base } = await setup() + // a0000 private → public, a0001 public → private, both unchanged; a0010 private, changed. + await pushDelta(h, user, base, { base: 'v1.0.0' }, [ + { id: 'a0000', type: 'Author', data: { name: 'Author 0' } }, + { id: 'a0001', type: 'Author', data: { name: 'Author 1' }, private: true }, + { id: 'a0010', type: 'Author', data: { name: 'Renamed' }, private: true }, + ]) + const since = (as?: string) => + h.request(`${base}/versions/v1.1.0/manifest?since=v1.0.0`, as ? { user: as } : {}).then(json) + const member = (await since(user)).delta + expect(member.added).toEqual([]) + expect(member.removed).toEqual([]) + const [a0, a1, a10] = member.updated + expect(a0).toMatchObject({ id: 'a0000', previousPrivate: true }) + expect(a0.private).toBeUndefined() + expect(a0.hash).toBe(a0.previousHash) + expect(a1).toMatchObject({ id: 'a0001', private: true, previousPrivate: false }) + expect(a1.hash).toBe(a1.previousHash) + expect(a10).toMatchObject({ id: 'a0010', private: true }) + expect(a10.hash).not.toBe(a10.previousHash) + expect(a10.previousPrivate).toBeUndefined() + // A public reader sees only the public set: one appeared, one left. + const anon = (await since()).delta + expect(anon.added).toEqual([{ id: 'a0000', type: 'Author', hash: a0.hash }]) + expect(anon.removed).toEqual([{ id: 'a0001', type: 'Author', hash: a1.hash }]) + expect(anon.updated).toEqual([]) + }) + + it('marks members’ private lines in NDJSON and resumes a stream after a (type, id)', async () => { + const { h, user, base } = await setup() + const stream = async (query: string, as?: string) => { + const res = await h.request(`${base}/versions/latest/records.ndjson${query}`, { + ...(as ? { user: as } : {}), + }) + expect(res.status).toBe(200) + const text = await res.text() + const lines = text ? text.trim().split('\n') : [] + // The count is exactly the lines sent. + expect(Number(res.headers.get('x-underlay-record-count'))).toBe(lines.length) + return lines + } + const all = await stream('', user) + expect(all).toHaveLength(1501) + const parsed = all.map((l) => JSON.parse(l)) + expect(parsed.filter((r) => r.private).length).toBe(121) + expect(parsed[0]).toMatchObject({ id: 'a0000', type: 'Author', private: true }) + expect(parsed[0].hash).toMatch(/^[0-9a-f]{64}$/) + expect(all[0]!.endsWith(`,"hash":"${parsed[0].hash}","private":true}`)).toBe(true) + expect(parsed[1].private).toBeUndefined() + expect(parsed.at(-1)).toMatchObject({ id: 's1', type: 'Secret', private: true }) + const anon = await stream('') + expect(anon).toHaveLength(1380) + expect(anon.some((l) => l.includes('"private"'))).toBe(false) + + // Resuming after any line continues with the next one, through later types. + for (const [lines, as] of [ + [all, user], + [anon, undefined], + ] as const) { + for (const i of [0, 1099, 1200, lines.length - 2, lines.length - 1]) { + const r = JSON.parse(lines[i]!) + const rest = await stream(`?after_type=${r.type}&after=${encodeURIComponent(r.id)}`, as) + expect(rest).toEqual(lines.slice(i + 1)) + } + } + // An id that isn't there resumes at the next one after it. + expect(await stream('?after_type=Author&after=a1199z')).toEqual( + anon.filter((l) => JSON.parse(l).type === 'Book'), + ) + // Within a type, as before. + const books = anon.filter((l) => JSON.parse(l).type === 'Book') + expect(await stream('?type=Book&after=b5')).toEqual( + books.filter((l) => JSON.parse(l).id > 'b5'), + ) + // `after` alone is ambiguous across types. + const bad = await h.request(`${base}/versions/latest/records.ndjson?after=b5`) + expect(bad.status).toBe(400) + expect((await bad.json()).error).toMatch(/after_type/) + expect((await h.request(`${base}/versions/latest/records.ndjson?after_type=Book`)).status).toBe( + 400, + ) + }) + + it('hides private collections entirely', async () => { + const { h, base } = await setup() + await h.ports.db.update(schema.collections).set({ public: false }) + expect((await h.request(base)).status).toBe(404) + expect((await h.request(`${base}/versions/latest/records`)).status).toBe(404) + expect((await json(await h.request('/api/collections'))).collections).toEqual([]) + }) +}) diff --git a/packages/server/test/reconcile.test.ts b/packages/server/test/reconcile.test.ts new file mode 100644 index 0000000..d484f4a --- /dev/null +++ b/packages/server/test/reconcile.test.ts @@ -0,0 +1,194 @@ +import { createHash } from 'node:crypto' + +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import { reconcileConfig, reconcileDue } from '../src/billing/reconcile.js' +import * as schema from '../src/db/schema.js' +import { cleanup, type Harness, harness } from './harness.js' + +afterAll(cleanup) +const perSweep = reconcileConfig.perSweep + +const sha = (s: string) => createHash('sha256').update(s).digest('hex') +const Doc = { type: 'object', properties: { title: { type: 'string' }, pdf: { type: 'object' } } } + +async function push(h: Harness, user: string, base: string, open: object, records: object[]) { + const sid = ( + (await (await h.request(`${base}/push`, { method: 'POST', user, json: open })).json()) as { + session_id: string + } + ).session_id + await h.request(`${base}/push/${sid}/records`, { method: 'POST', user, ndjson: records }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe(201) + await h.drain() +} + +async function reconcile(h: Harness, collectionId: string, full = false) { + await h.ports.jobs.enqueue({ type: 'reconcile.collection', collectionId, full }) + await h.drain() + const [c] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, collectionId)) + return c! +} + +describe('reconcile', () => { + it('finds nothing to correct after normal commits, and corrects what was broken', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('docs') + const base = '/api/collections/org/docs' + const pdf = 'pdf bytes' + await h.request(`${base}/files/${sha(pdf)}`, { method: 'PUT', user, body: pdf }) + await push(h, user, base, { schemas: { Doc } }, [ + { id: 'a', type: 'Doc', data: { title: 'A', pdf: { $file: `sha256:${sha(pdf)}` } } }, + { id: 'b', type: 'Doc', data: { title: 'B' } }, + ]) + await push(h, user, base, {}, [{ id: 'c', type: 'Doc', data: { title: 'C' } }]) + const before = await reconcile(h, c.id) + expect(before.reconcileReport).toBeNull() + expect(before.reconciledAt).not.toBeNull() + expect(before.refEvents).toBeGreaterThan(0) + const versions = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, c.id)) + expect(versions.every((v) => v.refEvents !== null && v.reconcileReport === null)).toBe(true) + + // Break every counter it keeps, and one row that predates per-version counts. + const v1 = versions.find((v) => v.seq === 1)! + const v2 = versions.find((v) => v.seq === 2)! + await h.ports.db + .update(schema.collections) + .set({ refEvents: 999, refBytes: 1, publicFilesRoot: null }) + .where(eq(schema.collections.id, c.id)) + await h.ports.db + .update(schema.versions) + .set({ recordCount: 0, typeCounts: {} }) + .where(eq(schema.versions.id, v1.id)) + await h.ports.db + .update(schema.versions) + .set({ refEvents: null, refBytes: null }) + .where(eq(schema.versions.id, v2.id)) + await h.ports.db.delete(schema.schemaUsage).where(eq(schema.schemaUsage.collectionId, c.id)) + + // A scheduled run checks only versions it hasn't: v1's row isn't looked at again. + const scheduled = await reconcile(h, c.id) + const seen = (scheduled.reconcileReport ?? []).map( + (d) => `${d.field}${d.seq ? `@${d.seq}` : ''}`, + ) + expect(seen.sort()).toEqual(['publicFilesRoot', 'refBytes', 'refEvents', 'schemaUsage'].sort()) + await h.ports.db + .update(schema.collections) + .set({ refEvents: 999, refBytes: 1, publicFilesRoot: null }) + .where(eq(schema.collections.id, c.id)) + await h.ports.db.delete(schema.schemaUsage).where(eq(schema.schemaUsage.collectionId, c.id)) + + // A steward's run checks every version. + const after = await reconcile(h, c.id, true) + const fields = (after.reconcileReport ?? []).map((d) => `${d.field}${d.seq ? `@${d.seq}` : ''}`) + expect(fields.sort()).toEqual( + [ + 'publicFilesRoot', + 'recordCount@1', + 'refBytes', + 'refEvents', + 'schemaUsage', + 'typeCounts@1', + ].sort(), + ) + expect(after).toMatchObject({ + refEvents: before.refEvents, + refBytes: before.refBytes, + publicFilesRoot: before.publicFilesRoot, + }) + const [fixed] = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, v1.id)) + expect(fixed).toMatchObject({ recordCount: v1.recordCount, typeCounts: v1.typeCounts }) + const [filled] = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.id, v2.id)) + expect(filled).toMatchObject({ + refEvents: v2.refEvents, + refBytes: v2.refBytes, + reconcileReport: null, + }) + expect( + await h.ports.db + .select() + .from(schema.schemaUsage) + .where(eq(schema.schemaUsage.collectionId, c.id)), + ).toHaveLength(1) + }) + + it('starts the runs that are due from the sweep', async () => { + const h = await harness() + await h.member() + const a = await h.collection('a') + await h.collection('b') + await h.collection('c') + reconcileConfig.perSweep = 2 + expect(await reconcileDue(h.ports)).toBe(2) + await h.drain() + // The two done aren't due again for a week; the third is. + expect(await reconcileDue(h.ports)).toBe(1) + await h.drain() + expect(await reconcileDue(h.ports)).toBe(0) + const [row] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, a.id)) + expect(row!.reconciledAt).not.toBeNull() + reconcileConfig.perSweep = perSweep + }) + + it('runs as a chain of small jobs, and rebuilds files from its last checkpoint', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('docs') + const base = '/api/collections/org/docs' + await h.ports.db.update(schema.collections).set({ public: true }) + for (let i = 0; i < 4; i++) { + const pdf = `pdf ${i}` + await h.request(`${base}/files/${sha(pdf)}`, { method: 'PUT', user, body: pdf }) + await push(h, user, base, i === 0 ? { schemas: { Doc } } : {}, [ + // Each version replaces the last one's file: the cumulative tree keeps them all. + { id: 'a', type: 'Doc', data: { title: `A${i}`, pdf: { $file: `sha256:${sha(pdf)}` } } }, + ]) + } + reconcileConfig.versionsPerJob = 1 + try { + const first = await reconcile(h, c.id) + expect(first.reconcileReport).toBeNull() + expect(first).toMatchObject({ reconciledSeq: 4, reconcileState: null }) + expect(first.reconciledFilesRoot).toBe(first.publicFilesRoot) + + // One more version; the next run starts its files tree from seq 4. + await h.request(`${base}/files/${sha('pdf 4')}`, { method: 'PUT', user, body: 'pdf 4' }) + await push(h, user, base, {}, [ + { id: 'a', type: 'Doc', data: { title: 'A4', pdf: { $file: `sha256:${sha('pdf 4')}` } } }, + ]) + const good = ( + await h.ports.db.select().from(schema.collections).where(eq(schema.collections.id, c.id)) + )[0]!.publicFilesRoot + await h.ports.db + .update(schema.collections) + .set({ publicFilesRoot: null }) + .where(eq(schema.collections.id, c.id)) + const second = await reconcile(h, c.id) + expect(second.reconcileReport?.map((d) => d.field)).toEqual(['publicFilesRoot']) + expect(second).toMatchObject({ reconciledSeq: 5, publicFilesRoot: good }) + for (let i = 0; i < 5; i++) { + const res = await h.request(`${base}/files/${sha(`pdf ${i}`)}`) + expect(res.status).toBe(302) + } + } finally { + reconcileConfig.versionsPerJob = 100 + } + }) +}) diff --git a/packages/server/test/refs.test.ts b/packages/server/test/refs.test.ts new file mode 100644 index 0000000..674ff0c --- /dev/null +++ b/packages/server/test/refs.test.ts @@ -0,0 +1,349 @@ +import { createHash } from 'node:crypto' + +import { hashRecord, memoryStore, noCache } from '@underlay/protocol' +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { eventsFor, FAN_IN, indexConfig } from '../src/refs/log.js' +import { lookupSegment, SegmentWriter } from '../src/refs/segments.js' +import { cleanup, type Harness, harness } from './harness.js' + +afterAll(cleanup) + +const Author = { type: 'object', properties: { name: { type: 'string' } } } +const h_ = (id: string, data: unknown) => hashRecord(id, 'Author', data).hash + +async function json(res: Response) { + return (await res.json()) as any +} + +async function push( + h: Harness, + user: string, + base: string, + body: object, + records: object[], + deletes: object[] = [], +) { + const sid = (await json(await h.request(`${base}/push`, { method: 'POST', user, json: body }))) + .session_id + if (records.length) + await h.request(`${base}/push/${sid}/records`, { method: 'POST', user, ndjson: records }) + if (deletes.length) + await h.request(`${base}/push/${sid}/deletes`, { method: 'POST', user, ndjson: deletes }) + const res = await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + expect(res.status).toBe(201) + await h.drain() + return json(res) +} + +describe('reference log', () => { + it('answers provenance across versions, sets and forks, filtered by access', async () => { + const h = await harness() + const user = await h.member() + await h.collection('lib') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/lib' + await push(h, user, base, { schemas: { Author } }, [ + { id: 'a', type: 'Author', data: { name: 'A' } }, + { id: 'b', type: 'Author', data: { name: 'B' }, private: true }, + ]) + await push(h, user, base, { base: 'v1.0.0' }, [ + { id: 'a', type: 'Author', data: { name: 'A2' } }, + ]) + + const oldA = await json(await h.request(`/api/records/${h_('a', { name: 'A' })}/provenance`)) + expect(oldA).toMatchObject({ recordId: 'a', type: 'Author', data: { name: 'A' } }) + expect(oldA.references.map((r: any) => r.semver)).toEqual(['v1.0.0']) + const newA = await json(await h.request(`/api/records/${h_('a', { name: 'A2' })}/provenance`)) + expect(newA.references.map((r: any) => r.semver)).toEqual(['v1.1.0']) + + // Private records: members only, and nothing (not even a count) for others. + const bHash = h_('b', { name: 'B' }) + expect((await h.request(`/api/records/${bHash}/provenance`)).status).toBe(404) + expect( + (await json(await h.request(`/api/records/${bHash}/provenance`, { user }))).references.length, + ).toBe(2) + + // A fork inherits presence from the fork point, until it removes the record. + await h.ports.db.insert(schema.organization).values({ id: 'org2', name: 'Two', slug: 'two' }) + await h.ports.db + .insert(schema.member) + .values({ organizationId: 'org2', userId: user, role: 'owner' }) + expect( + (await h.request(`${base}/fork`, { method: 'POST', user, json: { targetOrg: 'two' } })) + .status, + ).toBe(201) + await h.drain() + await h.ports.db.update(schema.collections).set({ public: true }) + let refs = (await json(await h.request(`/api/records/${h_('a', { name: 'A2' })}/provenance`))) + .references + expect(refs.map((r: any) => `${r.owner}/${r.semver}`).sort()).toEqual([ + 'org/v1.1.0', + 'two/v1.0.0', + ]) + await push( + h, + user, + '/api/collections/two/lib', + { base: 'v1.0.0' }, + [], + [{ type: 'Author', id: 'a' }], + ) + refs = (await json(await h.request(`/api/records/${h_('a', { name: 'A2' })}/provenance`))) + .references + expect(refs.map((r: any) => `${r.owner}/${r.semver}`).sort()).toEqual([ + 'org/v1.1.0', + 'two/v1.0.0', + ]) + + // Batch fetch by hash, and the counters. + const batch = await h.request('/api/records/batch', { + method: 'POST', + json: { hashes: [h_('a', { name: 'A2' }), bHash] }, + }) + const lines = (await batch.text()) + .trim() + .split('\n') + .map((l) => JSON.parse(l)) + expect(lines).toEqual([ + { id: 'a', type: 'Author', data: { name: 'A2' }, hash: h_('a', { name: 'A2' }) }, + ]) + const [col] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.slug, 'lib')) + expect(col!.refEvents).toBeGreaterThan(0) + }) + + it('indexes a large version in key-range units, live together', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('lib') + const base = '/api/collections/org/lib' + const authors = (n: number, tag: string) => + Array.from({ length: n }, (_, i) => ({ + id: `a${String(i).padStart(3, '0')}`, + type: 'Author', + data: { name: `${tag}${i}` }, + ...(i % 5 === 0 ? { private: true } : {}), + })) + indexConfig.unitEvents = 10 + try { + await push(h, user, base, { schemas: { Author } }, authors(60, 'x')) + await push(h, user, base, { base: 'v1.0.0' }, authors(60, 'y')) + } finally { + indexConfig.unitEvents = 1_000_000 + } + const versions = await h.ports.db + .select() + .from(schema.versions) + .where(eq(schema.versions.collectionId, c.id)) + expect(versions.every((v) => v.refsIndexed)).toBe(true) + // 60 added, then 60 updated (a removal and an addition each). + expect(versions.map((v) => v.refEvents).sort()).toEqual([120, 60]) + const runs = await h.ports.db.select().from(schema.refSegments) + expect(new Set(runs.map((r) => r.runId)).size).toBeGreaterThan(2) + expect(runs.every((r) => r.state === 'live')).toBe(true) + expect(await h.ports.db.select().from(schema.refIndexUnits)).toEqual([]) + const [row] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, c.id)) + expect(row!.refEvents).toBe(180) + // Every record's history comes out as it would from one run. + for (const i of [0, 1, 37, 59]) { + const id = `a${String(i).padStart(3, '0')}` + const events = await eventsFor(h.ports, h_(id, { name: `x${i}` })) + expect(events.map((e) => `${e[4]}${e[5]}`)).toEqual(['1+', '2-']) + } + }) + + it('compacts runs without changing answers', async () => { + const h = await harness() + const user = await h.member() + await h.collection('lib') + const base = '/api/collections/org/lib' + await push(h, user, base, { schemas: { Author } }, [ + { id: 'k0', type: 'Author', data: { name: '0' } }, + ]) + for (let i = 1; i < FAN_IN + 2; i++) { + await push(h, user, base, {}, [{ id: `k${i}`, type: 'Author', data: { name: String(i) } }]) + } + const runs = await h.ports.db + .select() + .from(schema.refSegments) + .where(eq(schema.refSegments.state, 'live')) + expect(new Set(runs.map((r) => r.runId)).size).toBeLessThan(FAN_IN + 2) + expect(runs.some((r) => r.tier === 1)).toBe(true) + for (let i = 0; i < FAN_IN + 2; i++) { + const ev = await eventsFor(h.ports, h_(`k${i}`, { name: String(i) })) + expect(ev.map((e) => e[5])).toEqual(['+']) + } + }) +}) + +describe('deleted collections', () => { + it('tombstones a deleted collection, and compaction drops its events', async () => { + const h = await harness() + const user = await h.member() + const gone = await h.collection('gone') + await h.collection('kept') + const shared = { id: 's', type: 'Author', data: { name: 'shared' } } + await push(h, user, '/api/collections/org/gone', { schemas: { Author } }, [shared]) + const before = await eventsFor(h.ports, h_('s', { name: 'shared' })) + expect(before.map((e) => e[2])).toEqual([gone.id]) + const [row] = await h.ports.db + .select() + .from(schema.collections) + .where(eq(schema.collections.id, gone.id)) + expect(row!.refEvents).toBeGreaterThan(0) + + expect((await h.request('/api/collections/org/gone', { method: 'DELETE', user })).status).toBe( + 200, + ) + const [tomb] = await h.ports.db.select().from(schema.collectionTombstones) + expect(tomb).toMatchObject({ + collectionId: gone.id, + slug: 'gone', + refEvents: row!.refEvents, + versions: 1, + deletedBy: user, + }) + + // Enough commits elsewhere to compact the run holding the deleted collection's events. + const base = '/api/collections/org/kept' + await push(h, user, base, { schemas: { Author } }, [shared]) + for (let i = 1; i < FAN_IN + 2; i++) { + await push(h, user, base, {}, [{ id: `k${i}`, type: 'Author', data: { name: String(i) } }]) + } + const after = await eventsFor(h.ports, h_('s', { name: 'shared' })) + expect(after.map((e) => e[2])).not.toContain(gone.id) + expect(after.length).toBeGreaterThan(0) + }) +}) + +describe('forks and access', () => { + const sha = (b: string) => createHash('sha256').update(b).digest('hex') + const Doc = { type: 'object', properties: { title: { type: 'string' }, pdf: {} } } + + it("a public-only fork doesn't inherit the parent's private files or records", async () => { + const h = await harness() + const user = await h.member() + await h.collection('lib') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/lib' + const pub = 'public pdf' + const priv = 'private pdf' + for (const b of [pub, priv]) { + expect( + (await h.request(`${base}/files/${sha(b)}`, { method: 'PUT', user, body: b })).status, + ).toBe(201) + } + await push(h, user, base, { schemas: { Doc } }, [ + { id: 'd1', type: 'Doc', data: { pdf: { $file: `sha256:${sha(pub)}` } } }, + { id: 'd2', type: 'Doc', data: { pdf: { $file: `sha256:${sha(priv)}` } }, private: true }, + ]) + + // A signed-in non-member forks the public collection into their own org. + await h.ports.db.insert(schema.user).values({ id: 'u2', name: 'u2', email: 'u2@example.org' }) + await h.ports.db.insert(schema.organization).values({ id: 'org2', name: 'Two', slug: 'two' }) + await h.ports.db + .insert(schema.member) + .values({ organizationId: 'org2', userId: 'u2', role: 'owner' }) + expect( + (await h.request(`${base}/fork`, { method: 'POST', user: 'u2', json: { targetOrg: 'two' } })) + .status, + ).toBe(201) + await h.drain() + + expect((await h.request(`/api/collections/files/${sha(priv)}`, { user: 'u2' })).status).toBe( + 404, + ) + const d2 = hashRecord('d2', 'Doc', { pdf: { $file: `sha256:${sha(priv)}` } }).hash + expect((await h.request(`/api/records/${d2}/provenance`, { user: 'u2' })).status).toBe(404) + // What the fork did carry stays reachable, and the parent's members still see both. + expect((await h.request(`/api/collections/files/${sha(pub)}`, { user: 'u2' })).status).toBe(302) + expect((await h.request(`/api/collections/files/${sha(priv)}`, { user })).status).toBe(302) + }) + + it('keeps what a fork inherited when it removes a record and adds it back', async () => { + const h = await harness() + const user = await h.member() + await h.collection('lib') + const base = '/api/collections/org/lib' + const a = { id: 'a', type: 'Author', data: { name: 'A' } } + await push(h, user, base, { schemas: { Author } }, [a]) + expect( + ( + await h.request(`${base}/fork`, { + method: 'POST', + user, + json: { targetOrg: 'org', slug: 'lib-fork' }, + }) + ).status, + ).toBe(201) + await h.drain() + const fork = '/api/collections/org/lib-fork' + await push(h, user, fork, { base: 'v1.0.0' }, [], [{ type: 'Author', id: 'a' }]) + await push(h, user, fork, {}, [a]) + const refs = ( + await json(await h.request(`/api/records/${h_('a', { name: 'A' })}/provenance`, { user })) + ).references + expect(refs.map((r: any) => `${r.collection}/${r.semver}`).sort()).toEqual([ + 'lib-fork/v1.0.0', + 'lib-fork/v1.2.0', + 'lib/v1.0.0', + ]) + }) + + it('serves a record body only when it has the requested hash', async () => { + const h = await harness() + const user = await h.member() + await h.collection('lib') + const base = '/api/collections/org/lib' + await push(h, user, base, { schemas: { Author } }, [ + { id: 'a', type: 'Author', data: { name: 'A' } }, + ]) + // The next version isn't indexed yet, so the log still places the old record at the head. + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { base: 'v1.0.0' } }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [{ id: 'a', type: 'Author', data: { name: 'A2' } }], + }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + const res = await h.request(`/api/records/${h_('a', { name: 'A' })}/provenance`, { user }) + if (res.status === 200) expect((await json(res)).data).toEqual({ name: 'A' }) + else expect(res.status).toBe(404) + const batch = await h.request('/api/records/batch', { + method: 'POST', + user, + json: { hashes: [h_('a', { name: 'A' })] }, + }) + for (const line of (await batch.text()).split('\n').filter(Boolean)) + expect(JSON.parse(line).data).toEqual({ name: 'A' }) + }) +}) + +describe('segments', () => { + it('never has false negatives, for any hash', async () => { + const store = memoryStore() + const hashes = Array.from({ length: 5000 }, (_, i) => + createHash('sha256').update(String(i)).digest('hex'), + ).sort() + const w = new SegmentWriter(store, () => 'seg') + for (const x of hashes) await w.add([x, 'r', 'c', 'public', 1, '+', 'T', x.slice(0, 6)]) + await w.finish() + for (const x of hashes.filter((_, i) => i % 50 === 0)) { + expect((await lookupSegment(store, noCache, 'seg', x)).length).toBe(1) + } + }) +}) diff --git a/packages/server/test/replicas.test.ts b/packages/server/test/replicas.test.ts new file mode 100644 index 0000000..6936f16 --- /dev/null +++ b/packages/server/test/replicas.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { readsAnyReplica } from '../src/db/replicas.js' + +const req = (path: string, init: RequestInit = {}) => new Request(`https://x.test${path}`, init) + +describe('read replicas', () => { + it('serve anonymous reads only', () => { + expect(readsAnyReplica(req('/umass/holdings'))).toBe(true) + expect(readsAnyReplica(req('/api/collections/a/b/versions', { method: 'HEAD' }))).toBe(true) + expect(readsAnyReplica(req('/api/collections', { method: 'POST' }))).toBe(false) + expect(readsAnyReplica(req('/x', { headers: { authorization: 'Bearer ul_x' } }))).toBe(false) + expect(readsAnyReplica(req('/api/collections/a/b/files/h?token=ul_x'))).toBe(false) + expect( + readsAnyReplica( + req('/dashboard', { headers: { cookie: '__Secure-better-auth.session_token=s' } }), + ), + ).toBe(false) + expect(readsAnyReplica(req('/x', { headers: { cookie: 'theme=dark' } }))).toBe(true) + expect(readsAnyReplica(req('/api/auth/oauth2/callback/kf-auth?code=c'))).toBe(false) + expect(readsAnyReplica(req('/login'))).toBe(false) + }) +}) diff --git a/packages/server/test/runs.test.ts b/packages/server/test/runs.test.ts new file mode 100644 index 0000000..ed85dbe --- /dev/null +++ b/packages/server/test/runs.test.ts @@ -0,0 +1,164 @@ +import { compareUtf8, MemoryStore, memoryStore, sha256Hex } from '@underlay/protocol' +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' + +import { + compactRuns, + inRunRange, + isMark, + mergeRuns, + readRun, + type RunEntry, + type RunIndex, + runMarks, + RunWriter, + writeRun, +} from '../src/push/runs.js' + +async function collect(it: AsyncIterable): Promise { + const out: T[] = [] + for await (const x of it) out.push(x) + return out +} + +/** A store that counts reads and the bytes they return. */ +class CountingStore extends MemoryStore { + gets = 0 + bytes = 0 + override async get(key: string, range?: { offset: number; length?: number }) { + const obj = await super.get(key, range) + if (obj && !key.endsWith('index.json')) { + this.gets++ + this.bytes += range?.length ?? 0 + } + return obj + } +} + +const key = (e: { t: string; k: string }) => `${e.t}\u0000${e.k}` + +describe('sorted runs', () => { + it('writes blocks into parts, reads them back in order, and filters by type', async () => { + const store = memoryStore() + const entries: RunEntry[] = [] + // Hex bodies barely compress, so this spans several 4 MB parts. + for (let i = 0; i < 12_000; i++) + entries.push({ + t: i % 2 ? 'B' : 'A', + k: `id${i}`, + b: Array.from({ length: 16 }, (_, j) => sha256Hex(`${i}.${j}`)).join(''), + }) + const ix = await writeRun(store, 's', 1, entries) + expect(ix.blocks.length).toBeGreaterThan(50) + expect(new Set(ix.blocks.map((b) => b.part)).size).toBeGreaterThan(1) + const all = await collect(readRun(store, 's', ix)) + expect(all.length).toBe(12_000) + expect(all.slice(0, 6000).every((e) => e.t === 'A')).toBe(true) + const onlyB = await collect(readRun(store, 's', ix, 'B')) + expect(onlyB.length).toBe(6000) + expect(onlyB.every((e) => e.t === 'B')).toBe(true) + }) + + it('refuses out-of-order writes', () => { + const w = new RunWriter(memoryStore(), 's', 1) + w.add({ t: 'A', k: 'b' }) + expect(() => w.add({ t: 'A', k: 'a' })).toThrow(/out of order/) + }) + + it('reads a key range by fetching only the blocks that overlap it', async () => { + const store = new CountingStore() + const ids = Array.from({ length: 40_000 }, (_, i) => `r${String(i).padStart(6, '0')}`) + const entries = ids.map((k) => ({ t: 'T', k, h: sha256Hex(k) })) + const ix = await writeRun(store, 's', 1, entries) + const total = ix.blocks.reduce((n, b) => n + b.length, 0) + const range = { type: 'T', after: ids[20_000]!, through: ids[20_999]! } + store.gets = 0 + store.bytes = 0 + const got = await collect(readRun(store, 's', ix, range)) + expect(got.map((e) => e.k)).toEqual(ids.slice(20_001, 21_000)) + expect(store.bytes).toBeLessThan(total / 10) + // A range in another type reads nothing. + store.gets = 0 + expect(await collect(readRun(store, 's', ix, { type: 'U' }))).toEqual([]) + expect(store.gets).toBe(0) + }) + + it('marks exactly the upserts whose id is a split-key candidate', async () => { + const store = memoryStore() + const entries: RunEntry[] = Array.from({ length: 60_000 }, (_, i) => ({ + t: 'T', + k: `id${i}`, + ...(i % 3 === 0 ? { x: true } : { h: 'h' }), + })) + const ix = await writeRun(store, 's', 1, entries) + const want = entries + .filter((e) => !e.x && isMark(e.k)) + .map(key) + .sort(compareUtf8) + expect(want.length).toBeGreaterThan(0) + expect(runMarks([ix])).toEqual(want) + const after = want[0]!.split('\u0000')[1]! + expect(runMarks([ix], { type: 'T', after })).toEqual(want.slice(1)) + }) + + it('merges any grouping of compactions, later uploads winning (property)', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.array(fc.tuple(fc.constantFrom('A', 'B', 'C'), fc.integer({ min: 0, max: 60 })), { + maxLength: 30, + }), + { minLength: 1, maxLength: 40, size: 'max' }, + ), + fc.array(fc.integer({ min: 0, max: 1000 }), { minLength: 1, maxLength: 20 }), + fc.tuple(fc.integer({ min: -1, max: 61 }), fc.integer({ min: -1, max: 61 })), + async (batches, picks, [a, b]) => { + const store = memoryStore() + const want = new Map() + let runs: RunIndex[] = [] + for (let seq = 1; seq <= batches.length; seq++) { + const entries = batches[seq - 1]!.map(([t, k]) => ({ t, k: `k${k}`, s: seq })) + for (const e of entries) want.set(key(e), seq) + runs.push(await writeRun(store, 's', seq, entries)) + } + // Compact arbitrary, non-adjacent groups of runs, repeatedly. + let seq = batches.length + 1 + for (const p of picks) { + if (runs.length < 2) break + const group = runs.filter((_, i) => (p >> (i % 10)) & 1 || i === p % runs.length) + if (group.length < 2) continue + const merged = await compactRuns(store, 's', group, seq++, 1) + runs = [...runs.filter((r) => !group.includes(r)), merged] + } + const merged = await collect(mergeRuns(store, 's', runs)) + const got = new Map(merged.map((e) => [key(e), e.s])) + expect(got).toEqual(want) + const keys = merged.map(key) + expect(keys).toEqual([...keys].sort(compareUtf8)) + // A range read of the merge agrees with filtering the whole merge. + const fmt = (n: number) => (n < 0 ? null : `k${n}`) + const range = { type: 'B', after: fmt(a), through: fmt(b) } + const ranged = await collect(mergeRuns(store, 's', runs, range)) + expect(ranged.map(key)).toEqual(merged.filter((e) => inRunRange(range, e)).map(key)) + }, + ), + { numRuns: 60 }, + ) + }) + + it('merges hundreds of runs with a heap', async () => { + const store = memoryStore() + const runs: RunIndex[] = [] + const want = new Map() + for (let seq = 1; seq <= 300; seq++) { + const entries = Array.from({ length: 50 }, (_, i) => { + const k = `k${(seq * 7919 + i * 104_729) % 5000}` + return { t: 'T', k, s: seq } + }) + for (const e of entries) want.set(e.k, seq) + runs.push(await writeRun(store, 's', seq, entries)) + } + const merged = await collect(mergeRuns(store, 's', runs)) + expect(new Map(merged.map((e) => [e.k, e.s]))).toEqual(want) + }) +}) diff --git a/packages/server/test/schemas.test.ts b/packages/server/test/schemas.test.ts new file mode 100644 index 0000000..89842ca --- /dev/null +++ b/packages/server/test/schemas.test.ts @@ -0,0 +1,102 @@ +import { hashSchema } from '@underlay/protocol' +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { cleanup, harness } from './harness.js' +import { setup } from './kf-app.js' + +afterAll(cleanup) + +const Author = { type: 'object', properties: { name: { type: 'string' } } } +const Secret = { type: 'object', private: true, properties: { note: { type: 'string' } } } + +async function json(res: Response) { + return (await res.json()) as any +} + +describe('schemas', () => { + it('lists collection schemas and global schemas by visibility, with labels', async () => { + const h = await harness() + const user = await h.member() + await h.collection('lib') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/lib' + const sid = ( + await json( + await h.request(`${base}/push`, { + method: 'POST', + user, + json: { schemas: { Author, Secret } }, + }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [ + { id: 'a', type: 'Author', data: { name: 'A' } }, + { id: 's', type: 'Secret', data: { note: 'n' } }, + ], + }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + + const anon = await json(await h.request(`${base}/schemas`)) + expect(anon.schemas.map((s: any) => s.slug)).toEqual(['Author']) + expect(anon.schemas[0]).toMatchObject({ schemaHash: hashSchema(Author), schema: Author }) + const owner = await json(await h.request(`${base}/schemas`, { user })) + expect(owner.schemas.map((s: any) => s.slug)).toEqual(['Author', 'Secret']) + + // Global: the private type's schema is visible to members only. + expect((await h.request(`/api/schemas/${hashSchema(Secret)}`)).status).toBe(404) + expect((await h.request(`/api/schemas/${hashSchema(Secret)}`, { user })).status).toBe(200) + const detail = await json(await h.request(`/api/schemas/${hashSchema(Author)}`)) + expect(detail.usage).toEqual([{ slug: 'Author', semver: 'v1.0.0', collection: 'org/lib' }]) + + expect( + ( + await h.request(`/api/schemas/${hashSchema(Author)}/labels`, { + method: 'POST', + user, + json: { label: 'person' }, + }) + ).status, + ).toBe(201) + const byLabel = await json(await h.request('/api/schemas?label=pers')) + expect(byLabel.map((s: any) => s.labels)).toEqual([['person']]) + const labelled = await json(await h.request(`${base}/schemas`)) + expect(labelled.schemas[0].schema['x-underlay-labels']).toEqual(['person']) + expect((await json(await h.request('/api/schemas?q=Auth'))).length).toBe(1) + }) + + it('lets only stewards remove a label, with a session or a non-read key', async () => { + const { call, db, user } = await setup() + await user('u1') // a steward in the fake KF Auth + await user('u2') + const hash = hashSchema(Author) + await db.insert(schema.schemas).values({ hash }) + await db.insert(schema.schemaLabels).values({ schemaHash: hash, label: 'person' }) + const remove = (u?: string, key?: string) => + call(`/api/schemas/${hash}/labels/person`, { + method: 'DELETE', + ...(u ? { user: u } : {}), + ...(key ? { key } : {}), + }) + const labels = async () => (await db.select().from(schema.schemaLabels)).length + + expect((await remove()).status).toBe(401) + // Anyone can mint an admin key: it doesn't make them a steward. + expect((await remove('u2', 'admin')).status).toBe(403) + expect((await remove('u2')).status).toBe(403) + // A steward's read key, or a key confined to a collection or owned by an org, isn't enough. + expect((await remove('u1', 'read')).status).toBe(401) + expect((await remove('u1', 'scoped')).status).toBe(401) + expect(await labels()).toBe(1) + expect((await remove('u1', 'write')).status).toBe(200) + expect(await labels()).toBe(0) + await db.insert(schema.schemaLabels).values({ schemaHash: hash, label: 'person' }) + expect((await remove('u1')).status).toBe(200) + expect(await labels()).toBe(0) + }) +}) diff --git a/packages/server/test/slug.test.ts b/packages/server/test/slug.test.ts new file mode 100644 index 0000000..0dd756d --- /dev/null +++ b/packages/server/test/slug.test.ts @@ -0,0 +1,62 @@ +import { readdir } from 'node:fs/promises' +import { join } from 'node:path' + +import { afterAll, describe, expect, it } from 'vitest' + +import { + defaultOrgSlugCandidate, + RESERVED_COLLECTION_SLUGS, + RESERVED_ORG_SLUGS, + validateCollectionSlug, + validateOrgSlug, +} from '../src/lib/slug.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +describe('top-level paths', () => { + it('are reserved org slugs, so no org page is shadowed', async () => { + const routes = join(import.meta.dirname, '../../web/src/routes') + const segments = async (dir: string) => { + const out = new Set() + for (const name of await readdir(dir)) { + const seg = name.replace(/\.(data\.)?tsx?$/, '') + if (!seg.startsWith('[') && seg !== 'index') out.add(seg) + } + return out + } + const top = await segments(routes) + expect(top.size).toBeGreaterThan(10) + for (const seg of top) expect(RESERVED_ORG_SLUGS, seg).toContain(seg) + // An org's own pages, likewise for collection slugs. + for (const seg of await segments(join(routes, '[owner]'))) + expect(RESERVED_COLLECTION_SLUGS, seg).toContain(seg) + expect(validateOrgSlug('protocol')).toBe('That slug is reserved') + expect(validateOrgSlug('billing')).toBe('That slug is reserved') + // Collections may use words only orgs can't: arxiv/search is fine, org/settings isn't. + expect(validateCollectionSlug('search')).toBeNull() + expect(validateCollectionSlug('settings')).toBe('That slug is reserved') + }) + + it('give personal orgs a free slug when the email names a reserved word', () => { + expect(defaultOrgSlugCandidate('ada@example.org')).toBe('ada') + expect(defaultOrgSlugCandidate('ada@example.org', 2)).toBe('ada-2') + expect(defaultOrgSlugCandidate('support@example.org')).toBe('support-1') + expect(defaultOrgSlugCandidate('support@example.org', 1)).toBe('support-2') + expect(defaultOrgSlugCandidate('a@example.org')).toBe('a-1') + for (const e of ['admin@x.org', 'user@x.org', '@x.org']) + expect(validateOrgSlug(defaultOrgSlugCandidate(e))).toBeNull() + }) + + it('redirect old blog and protocol URLs', async () => { + const h = await harness() + let res = await h.request('/blog/hello-World') + expect(res.status).toBe(301) + expect(res.headers.get('location')).toBe('https://www.knowledgefutures.org/updates/hello-world') + res = await h.request('/blog') + expect(res.headers.get('location')).toBe('https://www.knowledgefutures.org/?tag=underlay') + res = await h.request('/protocol') + expect(res.status).toBe(301) + expect(res.headers.get('location')).toBe('/docs/protocol') + }) +}) diff --git a/packages/server/test/stats.test.ts b/packages/server/test/stats.test.ts new file mode 100644 index 0000000..b8c39a8 --- /dev/null +++ b/packages/server/test/stats.test.ts @@ -0,0 +1,121 @@ +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { cleanup } from './harness.js' +import { setup } from './kf-app.js' + +afterAll(cleanup) + +const Author = { type: 'object', properties: { name: { type: 'string' } } } + +// u1 is a steward in the fake KF Auth; u2 is not. +describe('admin stats', () => { + it('reports the instance, per org, corpus, billing and operations to stewards only', async () => { + const { h, call, db, user } = await setup() + await user('u1') + await user('u2') + const col = await h.collection('authors') + await db.insert(schema.member).values({ organizationId: 'org1', userId: 'u1', role: 'owner' }) + const base = '/api/collections/org/authors' + const sid = ( + (await ( + await h.request(`${base}/push`, { + method: 'POST', + user: 'u1', + json: { schemas: { Author } }, + }) + ).json()) as { session_id: string } + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user: 'u1', + ndjson: [ + { id: 'a', type: 'Author', data: { name: 'A' } }, + { id: 'b', type: 'Author', data: { name: 'B' }, private: true }, + ], + }) + expect( + (await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user: 'u1' })).status, + ).toBe(201) + const today = new Date().toISOString().slice(0, 10) + await db.insert(schema.usageRollups).values([ + { day: today, accountId: 'org1', collectionId: col.id, metric: 'api_calls', amount: 7 }, + { + day: today, + accountId: 'org1', + collectionId: col.id, + metric: 'response_bytes', + amount: 900, + }, + { + day: '2001-01-01', + accountId: 'org1', + collectionId: col.id, + metric: 'api_calls', + amount: 99, + }, + ]) + + const get = async (path: string, u = 'u1') => { + const res = await call(path, { user: u }) + expect(res.status, path).toBe(200) + return (await res.json()) as any + } + expect((await call('/api/admin/stats/overview')).status).toBe(401) + expect((await call('/api/admin/stats/orgs', { user: 'u2' })).status).toBe(403) + + const overview = await get('/api/admin/stats/overview?days=7') + expect(overview.totals).toMatchObject({ + users: 2, + orgs: 1, + personalOrgs: 2, + collections: 1, + records: 2, + publicRecords: 1, + versions: 1, + }) + expect(overview.totals.latestBytes).toBeGreaterThan(0) + expect(overview.daily).toHaveLength(7) + expect(overview.daily.at(-1)).toMatchObject({ day: today, api_calls: 7 }) + expect(overview.usage).toMatchObject({ api_calls: 7, response_bytes: 900 }) + expect(overview.attention).toMatchObject({ openReports: 0, failedPushes: 0 }) + + const { orgs } = await get('/api/admin/stats/orgs') + const org = orgs.find((o: any) => o.slug === 'org') + expect(org).toMatchObject({ personal: false, members: 1, collections: 1, records: 2 }) + expect(org.usage.api_calls).toBe(7) + expect(orgs.find((o: any) => o.slug === 'u1')).toMatchObject({ personal: true, records: 0 }) + + const detail = await get('/api/admin/stats/orgs/org?days=30') + expect(detail.collections).toEqual([ + expect.objectContaining({ + slug: 'authors', + records: 2, + usage: expect.objectContaining({ api_calls: 7 }), + }), + ]) + expect(detail.members).toEqual([{ name: 'u1', email: 'u1@example.org', role: 'owner' }]) + expect((await call('/api/admin/stats/orgs/nope', { user: 'u1' })).status).toBe(404) + + const corpus = await get('/api/admin/stats/corpus') + expect(corpus.types).toEqual([{ type: 'Author', records: 2, publicRecords: 1, collections: 1 }]) + expect(corpus.sharedSchemas[0]).toMatchObject({ type: 'Author', collections: 1 }) + expect(corpus.largest[0]).toMatchObject({ owner: 'org', slug: 'authors', records: 2 }) + expect(corpus.growth).toEqual([ + expect.objectContaining({ month: today.slice(0, 7), versions: 1, added: 2 }), + ]) + + const billing = await get(`/api/admin/stats/billing?month=${today.slice(0, 7)}`) + expect(billing.orgs).toEqual([ + expect.objectContaining({ slug: 'org', usage: expect.objectContaining({ api_calls: 7 }) }), + ]) + expect(billing.reconcile).toMatchObject({ collections: 1, never: 1, corrected: [] }) + expect((await call('/api/admin/stats/billing?month=2026', { user: 'u1' })).status).toBe(400) + expect((await get('/api/admin/stats/billing?month=2001-01')).orgs[0].usage.api_calls).toBe(99) + + const ops = await get('/api/admin/stats/operations') + expect(ops.sessions).toEqual([{ status: 'committed', n: 1 }]) + expect(ops.failedPushes).toEqual([]) + expect(ops.locations).toEqual([]) + }) +}) diff --git a/packages/server/test/sync.test.ts b/packages/server/test/sync.test.ts new file mode 100644 index 0000000..fcc0791 --- /dev/null +++ b/packages/server/test/sync.test.ts @@ -0,0 +1,152 @@ +import { + iterate, + memoryStore, + openRepo, + type PackObject, + receiveVersion, + recordTree, + type Repo, + RepoSource, + untar, + verifyLogEntries, + type LogEntry, + type CollectionInfo, +} from '@underlay/protocol' +import { eq } from 'drizzle-orm' +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { cleanup, type Harness, harness } from './harness.js' + +afterAll(cleanup) + +const Author = { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } +const rec = (id: string, name: string, extra: Record = {}) => ({ + id, + type: 'Author', + data: { name }, + ...extra, +}) + +async function push(h: Harness, user: string, path: string, open: object, records: unknown[]) { + const res = await h.request(`${path}/push`, { method: 'POST', user, json: open }) + const sid = ((await res.json()) as { session_id: string }).session_id + await h.request(`${path}/push/${sid}/records`, { method: 'POST', user, ndjson: records }) + const commit = await h.request(`${path}/push/${sid}/commit`, { method: 'POST', user }) + expect(commit.status).toBe(201) + return (await commit.json()) as { semver: string; hash: string } +} + +async function* packObjects(res: Response): AsyncGenerator { + for await (const f of untar(res.body!)) yield { key: f.name, bytes: f.bytes } +} + +async function records(repo: Repo, version: string) { + const root = await repo.root(version) + const out: string[] = [] + for (const [slug, t] of Object.entries(root.public.types)) { + for await (const e of iterate(new RepoSource(recordTree, repo), t.root, { payloads: true })) { + out.push(`${slug}/${e.key}:${e.body}`) + } + } + return out +} + +async function setup() { + const h = await harness() + const user = await h.member() + const c = await h.collection('authors') + await h.ports.db + .update(schema.collections) + .set({ public: true }) + .where(eq(schema.collections.id, c.id)) + const path = '/api/collections/org/authors' + const v1 = await push(h, user, path, { schemas: { Author } }, [ + ...Array.from({ length: 2000 }, (_, i) => rec(`a${i}`, `n${i}`)), + rec('secret', 'hidden', { private: true }), + ]) + const v2 = await push(h, user, path, {}, [rec('a7', 'changed'), rec('b1', 'new')]) + return { h, user, c, path, v1, v2 } +} + +describe('sync endpoints', () => { + it('serves packs a client can receive, whole and incremental', async () => { + const { h, path, v1, v2 } = await setup() + const local = openRepo(memoryStore()) + const res1 = await h.request(`${path}/versions/${v1.semver}/pack`) + expect(res1.status).toBe(200) + expect(res1.headers.get('x-underlay-version')).toBe(v1.hash) + await receiveVersion(local, packObjects(res1), { target: v1.hash }) + const res2 = await h.request(`${path}/versions/${v2.semver}/pack?base=${v1.semver}`) + const got = await receiveVersion(local, packObjects(res2), { target: v2.hash, base: v1.hash }) + expect(got.objects).toBeLessThan(10) + + const server = await h.ports.stores.forCollection( + (await h.ports.db.select().from(schema.collections))[0]!.id, + ) + expect(await records(local, v2.hash)).toEqual(await records(server, v2.hash)) + expect((await records(local, v2.hash)).some((r) => r.includes('secret'))).toBe(false) + }) + + it('sends private sets only to members', async () => { + const { h, user, path, v1 } = await setup() + expect((await h.request(`${path}/versions/${v1.semver}/pack?sets=all`)).status).toBe(403) + const res = await h.request(`${path}/versions/${v1.semver}/pack?sets=all`, { user }) + expect(res.status).toBe(200) + const local = openRepo(memoryStore()) + await receiveVersion(local, packObjects(res), { target: v1.hash, sets: 'all' }) + const root = await local.root(v1.hash) + const priv = await local.privateSet(root.private!) + expect(priv.types.Author!.count).toBe(1) + }) + + it('hides private collections, and refuses foreign or unknown bases', async () => { + const { h, user, c, path, v1 } = await setup() + expect((await h.request(`${path}/versions/${v1.semver}/pack?base=v9.9.9`)).status).toBe(404) + expect((await h.request(`${path}/versions/${v1.semver}/pack?sets=some`)).status).toBe(400) + // A version of another collection is not a base here, even by hash. + await h.collection('other') + const other = await push(h, user, '/api/collections/org/other', { schemas: { Author } }, [ + rec('x', 'y'), + ]) + const foreign = await h.request(`${path}/versions/${v1.semver}/pack?base=${other.hash}`, { + user, + }) + expect(foreign.status).toBe(404) + await h.ports.db + .update(schema.collections) + .set({ public: false }) + .where(eq(schema.collections.id, c.id)) + expect((await h.request(`${path}/versions/${v1.semver}/pack`)).status).toBe(404) + expect((await h.request(`${path}/log`)).status).toBe(404) + expect((await h.request(`${path}/versions/${v1.semver}/pack`, { user })).status).toBe(200) + }) + + it('serves the signed log with the keys that verify it', async () => { + const { h, path, v1, v2 } = await setup() + const body = (await (await h.request(`${path}/log`)).json()) as { + collection: CollectionInfo + head: { seq: number; entryHash: string } + entries: LogEntry[] + } + expect(body.collection.keys).toHaveLength(1) + expect(body.collection.keys[0]!.id).toBe(h.signer.keyId) + expect(body.entries.map((e) => e.versionHash)).toEqual([v1.hash, v2.hash]) + const id = body.collection.id + const head = await verifyLogEntries(body.entries, body.collection.keys, null, id) + expect(head).toEqual({ seq: 2, entryHash: body.head.entryHash }) + // Incremental: entries after a verified head. + const rest = (await (await h.request(`${path}/log?after=1`)).json()) as { entries: LogEntry[] } + const first = await verifyLogEntries(body.entries.slice(0, 1), body.collection.keys, null, id) + expect(await verifyLogEntries(rest.entries, body.collection.keys, first, id)).toEqual(head) + // A tampered entry fails. + const bad = { ...rest.entries[0]!, message: 'forged' } + await expect(verifyLogEntries([bad], body.collection.keys, first, id)).rejects.toThrow( + /signature/, + ) + // So does a good entry checked as another collection's. + await expect( + verifyLogEntries(rest.entries, body.collection.keys, first, 'another'), + ).rejects.toThrow(/names collection/) + }) +}) diff --git a/packages/server/test/usage.test.ts b/packages/server/test/usage.test.ts new file mode 100644 index 0000000..c689055 --- /dev/null +++ b/packages/server/test/usage.test.ts @@ -0,0 +1,144 @@ +import { createHash } from 'node:crypto' + +import { afterAll, describe, expect, it } from 'vitest' + +import { + isolateUsageSink, + newUsageBuffer, + rebuildUsageDay, + rebuildUsageStep, + usageBatch, + type UsageEvent, + usageFor, + writeUsage, +} from '../src/billing/usage.js' +import * as schema from '../src/db/schema.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const sha = (s: string) => createHash('sha256').update(s).digest('hex') +const Doc = { type: 'object', properties: { title: { type: 'string' }, pdf: { type: 'object' } } } + +describe('usage', () => { + it('meters collection API calls and downloads to the collection’s owner', async () => { + const h = await harness() + const user = await h.member() + const c = await h.collection('docs') + await h.ports.db.update(schema.collections).set({ public: true }) + const base = '/api/collections/org/docs' + const pdf = 'pdf bytes!' + await h.request(`${base}/files/${sha(pdf)}`, { method: 'PUT', user, body: pdf }) + const sid = ( + (await ( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Doc } } }) + ).json()) as { + session_id: string + } + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [ + { id: 'a', type: 'Doc', data: { title: 'A', pdf: { $file: `sha256:${sha(pdf)}` } } }, + ], + }) + await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user }) + + const seen: UsageEvent[] = [] + h.ports.usage = { record: (events) => void seen.push(...events) } + const list = await h.request(`${base}/versions/latest/records`) + expect(list.status).toBe(200) + const listBytes = (await list.arrayBuffer()).byteLength + expect((await h.request(`${base}/files/${sha(pdf)}`)).status).toBe(302) + await h.request('/api/health') // not a collection: nothing billed + + const of = (m: string) => seen.filter((e) => e.m === m) + expect(of('api_calls')).toHaveLength(2) + expect(of('file_downloads')).toEqual([expect.objectContaining({ n: 1, c: c.id, a: 'org1' })]) + expect(of('file_bytes')[0]!.n).toBe(pdf.length) + // Counted as the body streamed out. + expect(of('response_bytes').map((e) => e.n)).toContain(listBytes) + expect(seen.every((e) => e.a === 'org1' && e.c === c.id)).toBe(true) + // Each request's events share its id, numbered from 0. + const ids = new Set(seen.map((e) => e.r)) + expect(ids.size).toBe(2) + + // Landed: the log and the rollups. A retried batch counts twice in the rollups + // until the day is rebuilt from the log. + await writeUsage(h.ports, seen) + await writeUsage(h.ports, seen) + const day = new Date(seen[0]!.t).toISOString().slice(0, 10) + const calls = async () => + (await usageFor(h.ports, day, 'org1')).find((r) => r.metric === 'api_calls')?.amount + expect(await calls()).toBe(4) + expect(await rebuildUsageDay(h.ports, day)).toBe(seen.length) + expect(await calls()).toBe(2) + + // A response the client abandons still counts its call, and its bytes so far. + seen.length = 0 + const gone = await h.request(`${base}/versions/latest/records.ndjson`) + await gone.body!.cancel() + expect(of('api_calls')).toHaveLength(1) + // records.ndjson.gz bills the NDJSON it decompresses to, not the gzip bytes. + seen.length = 0 + const gz = await h.request(`${base}/versions/latest/records.ndjson.gz`) + const wire = (await gz.arrayBuffer()).byteLength + const billed = of('response_bytes')[0]!.n + const ndjson = await (await h.request(`${base}/versions/latest/records.ndjson`)).text() + expect(billed).not.toBe(wire) + // Canonical lines, without the `,"hash":…` (74 bytes) records.ndjson adds. + expect(billed).toBe(new TextEncoder().encode(ndjson).byteLength - 74) + }) + + it('sends an isolate’s events in batches, and writes them directly when sending fails', async () => { + const h = await harness() + const buffer = newUsageBuffer() + const sent: UsageEvent[][] = [] + const written: UsageEvent[][] = [] + const waits: Promise[] = [] + let failing = false + const sink = isolateUsageSink( + buffer, + (p) => waits.push(p), + async (events) => { + if (failing) throw new Error('queue down') + sent.push(events) + }, + async (events) => void written.push(events), + ) + const ev = (r: string): UsageEvent => ({ + r, + i: 0, + t: Date.now(), + a: 'org1', + c: 'c', + m: 'api_calls', + n: 1, + }) + usageBatch.flushMs = 10 + try { + for (let i = 0; i < 5; i++) sink.record([ev(`r${i}`)]) + await Promise.all(waits) + expect(sent.map((b) => b.length)).toEqual([5]) + // A full buffer goes at once. + usageBatch.maxEvents = 3 + sink.record([ev('a'), ev('b'), ev('c')]) + await Promise.all(waits) + expect(sent.map((b) => b.length)).toEqual([5, 3]) + failing = true + sink.record([ev('x')]) + await Promise.all(waits) + expect(written.map((b) => b.map((e) => e.r))).toEqual([['x']]) + } finally { + usageBatch.flushMs = 5_000 + usageBatch.maxEvents = 400 + } + + // A day rebuilds a listing page per step. + const day = new Date().toISOString().slice(0, 10) + for (let i = 0; i < 3; i++) await writeUsage(h.ports, [ev(`d${i}`)]) + expect(await rebuildUsageStep(h.ports, day, 0)).toBeNull() + expect((await usageFor(h.ports, day, 'org1'))[0]!.amount).toBe(3) + }) +}) diff --git a/packages/server/test/view.test.ts b/packages/server/test/view.test.ts new file mode 100644 index 0000000..0dedf0d --- /dev/null +++ b/packages/server/test/view.test.ts @@ -0,0 +1,83 @@ +import { + type Change, + compareUtf8, + hashRecord, + hashSchema, + type RecordEntry, + utf8ByteLength, +} from '@underlay/protocol' +import { afterAll, describe, expect, it } from 'vitest' + +import { commitVersion } from '../src/versions/commit.js' +import { getRecord, loadView, typeRecords } from '../src/versions/view.js' +import { cleanup, harness } from './harness.js' + +afterAll(cleanup) + +const Author = { type: 'object' } +const up = (id: string, n: number): Change => { + const { hash, canonical } = hashRecord(id, 'Author', { n }) + return { key: id, entry: { key: id, hash, size: utf8ByteLength(canonical), body: canonical } } +} + +async function collect(it: AsyncIterable): Promise { + const out: T[] = [] + for await (const x of it) out.push(x) + return out +} + +describe('version views', () => { + it('pages owners across both sets by offset and by key, and hides private records from others', async () => { + const h = await harness() + const c = await h.collection() + const ids = Array.from( + { length: 2500 }, + (_, i) => `id${String((i * 7919) % 2500).padStart(5, '0')}`, + ) + const isPrivate = (id: string) => Number(id.slice(2)) % 3 === 0 + const sort = (cs: Change[]) => cs.sort((a, b) => compareUtf8(a.key, b.key)) + const r = await commitVersion(h.ports, { + collectionId: c.id, + base: null, + types: [ + { + slug: 'Author', + schema: Author, + schemaHash: hashSchema(Author), + public: sort(ids.filter((id) => !isPrivate(id)).map((id, i) => up(id, i))), + private: sort(ids.filter(isPrivate).map((id, i) => up(id, i))), + }, + ], + metadata: null, + }) + if (r.status !== 'committed') throw new Error(r.status) + const repo = await h.ports.stores.forCollection(c.id) + const owner = await loadView(repo, r.version, true) + const reader = await loadView(repo, r.version, false) + const all = [...ids].sort(compareUtf8) + const pub = all.filter((id) => !isPrivate(id)) + expect(owner.types[0]!.count).toBe(2500) + expect(reader.types[0]!.count).toBe(pub.length) + + for (const offset of [0, 1, 833, 834, 1250, 2499, 2500]) { + const page = (await collect(typeRecords(owner, owner.types[0]!, { offset }))) + .slice(0, 3) + .map((e) => e.key) + expect(page).toEqual(all.slice(offset, offset + 3)) + } + for (const offset of [0, 5, 1000, pub.length - 1]) { + const page = (await collect(typeRecords(reader, reader.types[0]!, { offset }))) + .slice(0, 3) + .map((e) => e.key) + expect(page).toEqual(pub.slice(offset, offset + 3)) + } + const afterKey = await collect(typeRecords(owner, owner.types[0]!, { after: all[99]! })) + expect(afterKey[0]!.key).toBe(all[100]) + + const secret = all.find(isPrivate)! + expect((await getRecord(owner, owner.types[0]!, secret))?.set).toBe('private') + expect(await getRecord(reader, reader.types[0]!, secret)).toBe(null) + const visible = await getRecord(reader, reader.types[0]!, pub[0]!) + expect(JSON.parse(visible!.body!).id).toBe(pub[0]) + }) +}) diff --git a/packages/server/test/webhooks.test.ts b/packages/server/test/webhooks.test.ts new file mode 100644 index 0000000..3e4beb0 --- /dev/null +++ b/packages/server/test/webhooks.test.ts @@ -0,0 +1,147 @@ +import { createHmac } from 'node:crypto' + +import { afterAll, describe, expect, it } from 'vitest' + +import * as schema from '../src/db/schema.js' +import { isPrivateIp, validateWebhookUrl } from '../src/webhooks/webhooks.js' +import { cleanup, harness, outboundCalls, respondOutbound } from './harness.js' + +afterAll(cleanup) + +const Author = { type: 'object', properties: { name: { type: 'string' } } } + +async function json(res: Response) { + return (await res.json()) as any +} + +describe('webhook URL checks', () => { + it('refuses private and local targets', () => { + for (const u of [ + 'http://example.org', + 'https://localhost/x', + 'https://10.0.0.1/', + 'https://[::1]/', + 'https://169.254.169.254/', + 'https://svc.internal/', + ]) { + expect(validateWebhookUrl(u, false).ok).toBe(false) + } + expect(validateWebhookUrl('https://hooks.example.org/underlay', false).ok).toBe(true) + expect(isPrivateIp('::ffff:192.168.1.1')).toBe(true) + expect(isPrivateIp('8.8.8.8')).toBe(false) + }) +}) + +describe('webhooks', () => { + it('delivers signed version.created events after a push, and retries failures', async () => { + const h = await harness() + const user = await h.member() + await h.collection('hooked') + const base = '/api/collections/org/hooked' + let res = await h.request(`${base}/webhooks`, { + method: 'POST', + user, + json: { url: 'https://hooks.example.org/u', bumpFilter: ['major', 'minor'] }, + }) + expect(res.status).toBe(201) + const hook = await json(res) + expect(hook.secret).toMatch(/^ulwhsec_/) + const listed = await json(await h.request(`${base}/webhooks`, { user })) + expect(listed.webhooks[0]).not.toHaveProperty('secret') + + outboundCalls.length = 0 + let fail = true + respondOutbound(() => (fail ? new Response('nope', { status: 500 }) : new Response('ok'))) + + const sid = ( + await json( + await h.request(`${base}/push`, { method: 'POST', user, json: { schemas: { Author } } }), + ) + ).session_id + await h.request(`${base}/push/${sid}/records`, { + method: 'POST', + user, + ndjson: [{ id: 'a', type: 'Author', data: { name: 'A' } }], + }) + expect((await h.request(`${base}/push/${sid}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + await h.drain() + expect(outboundCalls.length).toBe(1) + const call = outboundCalls[0]! + const body = String(call.init.body) + const headers = call.init.headers as Record + expect(headers['x-underlay-signature']).toBe( + `sha256=${createHmac('sha256', hook.secret).update(body).digest('hex')}`, + ) + expect(JSON.parse(body)).toMatchObject({ + event: 'version.created', + collection: { owner: 'org', slug: 'hooked' }, + version: { semver: 'v1.0.0' }, + bumpType: 'major', + }) + + // The failure was recorded and a retry queued with a delay. + let deliveries = ( + await json(await h.request(`${base}/webhooks/${hook.id}/deliveries`, { user })) + ).deliveries + expect(deliveries[0]).toMatchObject({ status: 'failed', attempts: 1, responseCode: 500 }) + fail = false + await h.ports.db.update(schema.jobs).set({ runAt: new Date(0) }) + await h.drain() + deliveries = (await json(await h.request(`${base}/webhooks/${hook.id}/deliveries`, { user }))) + .deliveries + expect(deliveries[0]).toMatchObject({ status: 'success', attempts: 2 }) + + // A patch bump doesn't match the filter. + const meta = ( + await json( + await h.request(`${base}/push`, { + method: 'POST', + user, + json: { base: 'v1.0.0', metadata_patch: { title: 'x' } }, + }), + ) + ).session_id + expect((await h.request(`${base}/push/${meta}/commit`, { method: 'POST', user })).status).toBe( + 201, + ) + outboundCalls.length = 0 + await h.drain() + expect(outboundCalls.length).toBe(0) + + // A test delivery: a signed ping, whatever the filter, logged like any other. + res = await h.request(`${base}/webhooks/${hook.id}/test`, { method: 'POST', user }) + expect(res.status).toBe(200) + const { deliveryId } = await json(res) + await h.drain() + expect(outboundCalls.length).toBe(1) + expect(JSON.parse(String(outboundCalls[0]!.init.body))).toMatchObject({ + event: 'ping', + collection: { owner: 'org', slug: 'hooked' }, + test: true, + }) + deliveries = (await json(await h.request(`${base}/webhooks/${hook.id}/deliveries`, { user }))) + .deliveries + expect(deliveries[0]).toMatchObject({ id: deliveryId, event: 'ping', status: 'success' }) + expect((await h.request(`${base}/webhooks/nope/test`, { method: 'POST', user })).status).toBe( + 404, + ) + }) + + it('is for org owners and admins only', async () => { + const h = await harness() + await h.member('owner1') + await h.collection('hooked') + await h.ports.db.insert(schema.user).values({ id: 'm2', name: 'm', email: 'm2@example.org' }) + await h.ports.db + .insert(schema.member) + .values({ organizationId: 'org1', userId: 'm2', role: 'member' }) + expect((await h.request('/api/collections/org/hooked/webhooks', { user: 'm2' })).status).toBe( + 403, + ) + expect( + (await h.request('/api/collections/org/hooked/webhooks', { user: 'owner1' })).status, + ).toBe(200) + }) +}) diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..56dc69f --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "target": "ES2024", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src", "test", "scripts", "drizzle.config.ts"] +} diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts new file mode 100644 index 0000000..3e42797 --- /dev/null +++ b/packages/server/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}) diff --git a/packages/server/wrangler.jsonc b/packages/server/wrangler.jsonc new file mode 100644 index 0000000..3e36847 --- /dev/null +++ b/packages/server/wrangler.jsonc @@ -0,0 +1,188 @@ +// Underlay on Cloudflare Workers: one Worker per deployment, each with its own +// D1 database, R2 bucket and queues (edge-redesign-build.md, "Deployment targets"). +// Resources are named for the deployment's role, not the app version or host, so +// they outlive both. +// +// wrangler deploy --env staging → staging.underlay.org +// wrangler deploy --env prod → www.underlay.org +// +// Secrets (wrangler secret put --env ): +// R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY bucket-scoped R2 S3 credentials +// SIGNING_KEY Ed25519 seed for version logs (one per deployment) +// LOCATION_KEY 32 bytes base64url; encrypts storage credentials +// SESSION_SECRET better-auth secret +// OIDC_CLIENT_SECRET kf_underlay secret of that env's KF Auth instance +// (staging: dev's, as v1 dev; next: prod's) +// AUTH_INTERNAL_API_KEY optional: KF Auth's internal API key (as v1). Without +// it new orgs can't link a KF org and +// /api/kf/summary answers 401. +// ASSETS_ACCESS_KEY_ID, ASSETS_SECRET_ACCESS_KEY +// optional: R2 S3 keys for ASSETS_BUCKET when there's +// no PUBLIC_ASSETS binding (else the R2_* keys) +// +// Public assets (org logos) go to a world-readable bucket per deployment, served at +// ASSETS_BASE_URL: staging's own, and prod's underlay-prod-public, which takes over +// assets.underlay.org from v1's underlay-public at the DNS switch (v1's objects are +// copied over, so migrated avatar URLs keep resolving). The bucket is the +// PUBLIC_ASSETS binding, or +// ASSETS_BUCKET through the S3 API. With neither, avatar uploads answer 503 and +// everything else runs. +// +// D1 migrations: wrangler d1 migrations apply --env --remote +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "underlay", + "main": "src/worker.ts", + "compatibility_date": "2026-09-30", + "compatibility_flags": ["nodejs_compat"], + // Jobs keep their own budgets under 10,000 subrequests (cleanup/config.ts and the + // job chains); the higher cap is headroom for a whole-repository walk that runs long. + "limits": { "cpu_ms": 300000, "subrequests": 100000 }, + // The UI's client build (pnpm --filter @underlay/web build). Matching files are + // served before the Worker runs; everything else reaches the Worker. + "assets": { "directory": "../web/dist/client" }, + "observability": { "enabled": true }, + "env": { + "staging": { + "name": "underlay-staging", + "routes": [{ "pattern": "staging.underlay.org", "custom_domain": true }], + "vars": { + "APP_URL": "https://staging.underlay.org", + "DEPLOYMENT": "staging", + "R2_ENDPOINT": "https://b66a542ad5e0af992703a2ce8d1d8747.r2.cloudflarestorage.com", + "R2_BUCKET": "underlay-staging", + // staging replaces dev.underlay.org: dev's data, dev's KF Auth. + "OIDC_ISSUER_URL": "https://dev-auth.knowledgefutures.org", + "OIDC_ACCOUNT_URL": "https://dev-account.knowledgefutures.org", + "OIDC_CLIENT_ID": "kf_underlay", + // underlay-staging-public's custom domain. + "ASSETS_BASE_URL": "https://assets-staging.underlay.org", + }, + // Repository and internal objects through the binding (no request signing, no + // subrequest per object); file bytes keep the S3 API, which can presign. + // PUBLIC_ASSETS: the world-readable bucket for org logos. + "r2_buckets": [ + { "binding": "BUCKET", "bucket_name": "underlay-staging" }, + { "binding": "PUBLIC_ASSETS", "bucket_name": "underlay-staging-public" }, + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "underlay-staging", + "database_id": "884ed8bb-14e8-4ea4-bdc4-81d433e5b551", + "migrations_dir": "drizzle", + }, + ], + // v1's budgets per minute (lib/limits.ts). namespace_id is unique per account. + "ratelimits": [ + { "name": "RL_ANON", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }, + { "name": "RL_USER", "namespace_id": "1002", "simple": { "limit": 5000, "period": 60 } }, + { "name": "RL_PAGE", "namespace_id": "1003", "simple": { "limit": 600, "period": 60 } }, + ], + // Interactive jobs, and bulk ones (one per invocation): jobs.ts BULK_JOBS. + "queues": { + "producers": [ + { "binding": "JOBS", "queue": "underlay-staging-jobs" }, + { "binding": "JOBS_BULK", "queue": "underlay-staging-bulk" }, + { "binding": "USAGE", "queue": "underlay-staging-usage" }, + ], + "consumers": [ + { + "queue": "underlay-staging-jobs", + "max_batch_size": 10, + "max_concurrency": 10, + "max_retries": 10, + "dead_letter_queue": "underlay-staging-dead", + }, + { + "queue": "underlay-staging-bulk", + "max_batch_size": 1, + "max_concurrency": 20, + "max_retries": 10, + "dead_letter_queue": "underlay-staging-dead", + }, + // Usage events (billing/usage.ts): up to 100 requests' events per log object. + { + "queue": "underlay-staging-usage", + "max_batch_size": 100, + "max_batch_timeout": 30, + "max_retries": 10, + "dead_letter_queue": "underlay-staging-dead", + }, + ], + }, + "triggers": { "crons": ["*/10 * * * *"] }, + }, + "prod": { + "name": "underlay-prod", + // underlay.org redirects to www in a Cloudflare rule, paths kept (ARK links). + "routes": [{ "pattern": "www.underlay.org", "custom_domain": true }], + "vars": { + "APP_URL": "https://www.underlay.org", + "DEPLOYMENT": "prod", + "R2_ENDPOINT": "https://b66a542ad5e0af992703a2ce8d1d8747.r2.cloudflarestorage.com", + "R2_BUCKET": "underlay-prod", + "OIDC_ISSUER_URL": "https://auth.knowledgefutures.org", + "OIDC_ACCOUNT_URL": "https://account.knowledgefutures.org", + "OIDC_CLIENT_ID": "kf_underlay", + // v1's public assets domain. It serves v1's underlay-public until the DNS switch, + // then underlay-prod-public (PUBLIC_ASSETS); logos uploaded before then don't show. + "ASSETS_BASE_URL": "https://assets.underlay.org", + }, + // Repository and internal objects through the binding (no request signing, no + // subrequest per object); file bytes keep the S3 API, which can presign. + // PUBLIC_ASSETS: the world-readable bucket for org logos. + "r2_buckets": [ + { "binding": "BUCKET", "bucket_name": "underlay-prod" }, + { "binding": "PUBLIC_ASSETS", "bucket_name": "underlay-prod-public" }, + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "underlay-prod", + "database_id": "12e03c06-7ba6-42af-baf5-7ce5a0be0ee8", + "migrations_dir": "drizzle", + }, + ], + // v1's budgets per minute (lib/limits.ts). namespace_id is unique per account. + "ratelimits": [ + { "name": "RL_ANON", "namespace_id": "2001", "simple": { "limit": 60, "period": 60 } }, + { "name": "RL_USER", "namespace_id": "2002", "simple": { "limit": 5000, "period": 60 } }, + { "name": "RL_PAGE", "namespace_id": "2003", "simple": { "limit": 600, "period": 60 } }, + ], + // Interactive jobs, and bulk ones (one per invocation): jobs.ts BULK_JOBS. + "queues": { + "producers": [ + { "binding": "JOBS", "queue": "underlay-prod-jobs" }, + { "binding": "JOBS_BULK", "queue": "underlay-prod-bulk" }, + { "binding": "USAGE", "queue": "underlay-prod-usage" }, + ], + "consumers": [ + { + "queue": "underlay-prod-jobs", + "max_batch_size": 10, + "max_concurrency": 10, + "max_retries": 10, + "dead_letter_queue": "underlay-prod-dead", + }, + { + "queue": "underlay-prod-bulk", + "max_batch_size": 1, + "max_concurrency": 20, + "max_retries": 10, + "dead_letter_queue": "underlay-prod-dead", + }, + // Usage events (billing/usage.ts): up to 100 requests' events per log object. + { + "queue": "underlay-prod-usage", + "max_batch_size": 100, + "max_batch_timeout": 30, + "max_retries": 10, + "dead_letter_queue": "underlay-prod-dead", + }, + ], + }, + "triggers": { "crons": ["*/10 * * * *"] }, + }, + }, +} diff --git a/index.html b/packages/web/index.html similarity index 100% rename from index.html rename to packages/web/index.html diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..f5a8961 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "@underlay/web", + "version": "0.0.0", + "private": true, + "description": "Underlay v2 UI: React Router pages, a client bundle, and renderPage for SSR on Workers and Node.", + "type": "module", + "exports": { + ".": "./dist/server/entry-server.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "vite build && vite build --ssr", + "typecheck": "tsc --noEmit", + "smoke": "tsx scripts/ssr-smoke.ts" + }, + "dependencies": { + "@better-auth/api-key": "^1.6.11", + "better-auth": "^1.6.11", + "marked": "^18.0.3", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router": "^7.15.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^25.6.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "tailwindcss": "^4.3.0", + "tsx": "^4.21.0", + "typescript": "^6.0.3", + "vite": "^6.4.2" + } +} diff --git a/packages/web/public/_headers b/packages/web/public/_headers new file mode 100644 index 0000000..ac7c7f1 --- /dev/null +++ b/packages/web/public/_headers @@ -0,0 +1,4 @@ +# Cloudflare static assets: response headers by path. Plain-text files need an +# explicit charset, or browsers read UTF-8 as Windows-1252 (an arrow shows as →). +/*.txt + Content-Type: text/plain; charset=utf-8 diff --git a/public/favicon.svg b/packages/web/public/favicon.svg similarity index 100% rename from public/favicon.svg rename to packages/web/public/favicon.svg diff --git a/packages/web/public/llms.txt b/packages/web/public/llms.txt new file mode 100644 index 0000000..88fff6c --- /dev/null +++ b/packages/web/public/llms.txt @@ -0,0 +1,782 @@ +# Underlay - AI Integration Guide + +Underlay is a versioned, content-addressed registry for structured data. Apps publish versions of +their data as JSON records with JSON Schemas; Underlay keeps every version and serves it over an +HTTPS API. Built by Knowledge Futures (501c3): https://www.knowledgefutures.org + +Base URL: https://www.underlay.org/api +Protocol specification: https://www.underlay.org/docs/protocol + +--- + +## Authentication + +1. API key (programmatic access): + Header: Authorization: Bearer ul_ + On GET and HEAD the key may instead be passed as ?token=ul_ (share links). + Every key starts with "ul_" and belongs to the user who creates it. Create keys at + https://www.underlay.org/settings/keys, at https://www.underlay.org/:owner/settings/keys (which can + confine a key to one of the organization's collections), or with the API (see "API Keys"). + A key has a scope, read, write or admin, and may be confined to some collections. + +2. Session cookie (browser use): + Sign in through KF Auth SSO at https://www.underlay.org/login. An account, with a personal + organization, is created on first sign-in. + +Public data needs no authentication. Publishing, settings and private data need a key or a +session. A Bearer key that doesn't verify is refused with 401 {"error": "Invalid or expired API +key"}; it is never treated as anonymous. + +What a key may do: +- read: read what its holder may read. +- write: also publish (push, files, metadata, fork). Acts as a plain member of the org. +- admin: keeps its holder's full role (owners and admins may then change visibility, delete, + transfer, manage webhooks, mirrors and ARK settings). A member's admin key is still a member's. + Changing or deleting an organization, setting its NAAN and deleting your account also need a + session or an admin key: an owner's write key gets 403. +- A key confined to collections (metadata.collectionIds) works only on those collections, and is + refused on account and organization endpoints and on fork. On its collections it acts as a + member whatever its scope: it may push, but visibility, deletion, transfer, webhooks and + mirrors are 403. + +Agent links: a collection's Share panel can create https://www.underlay.org/agent/ul_, a +write key confined to that collection that expires after 1 hour. GET /agent/:key is an HTML page +telling an AI agent how to push to the collection with that key (404 once expired or revoked). + +## Rate Limits + +Requests spend units from a per-minute budget: + +| Caller | Budget | +|-----------------------------------------------|-----------------------| +| Anonymous, /api/* | 60 units/min per IP | +| Anonymous, web pages (and ARK, /api/auth/*) | 600 units/min per IP | +| Authenticated (per user; per org for org keys)| 5,000 units/min | + +Most requests cost 1 unit. Expensive ones cost more: +- export: 20 +- .../versions/:n/records.ndjson(.gz) and .../versions/:n/pack: 10 +- POST /api/records/batch, /api/records/:hash/provenance, .../diff, .../history: 5 + +Over budget: 429 {"error": "Rate limit exceeded"} with Retry-After: 60. There are no +X-RateLimit-* headers. Authenticate for any automated access. On Cloudflare the limit is counted +per data center and is approximate. + +--- + +## Core Concepts + +- Organization: owns collections. Every user has a personal organization. Identified by :owner + (its slug). +- Collection: a named, versioned body of data owned by an organization. Identified by + :owner/:slug, or by its id as _/:collectionId (see "Collection URLs"). Public or private. +- Version: an immutable state of a collection: schemas, records, files and metadata. Identified + by semver ("v1.2.0"), by version hash ("ulv2:"), or as "latest". +- Record: {id, type, data}. Its hash is the SHA-256 of its canonical form (see "Record Hashing"). + Within a version, (type, id) identifies one record; the same id may appear under two types. +- Type: a class of records with one JSON Schema (draft-07). +- File: bytes identified by their SHA-256, referenced from record data as + {"$file": "sha256:"}. +- Access sets: each version has a public set and a private set. Members of the owning + organization read both; everyone else reads the public set. +- Schema labels: schemas can be labeled with URIs or names (e.g. "schema.org/Person") for + cross-collection discovery. + +--- + +## Web URLs (for linking humans to a view) + +The API paths below are for fetching data. To point a person at something in the browser, use +these page URLs. A version is a path prefix, a view is a path segment, and no version prefix +means "latest". Page URLs write semver without the "v" (/v/1.2.0); /v/v1.2.0 redirects there. + +https://www.underlay.org/:owner → organization or user profile +https://www.underlay.org/:owner/:slug → collection overview (latest) +https://www.underlay.org/:owner/:slug/records → browse records (?type=TypeName) +https://www.underlay.org/:owner/:slug/schemas → schemas (latest) +https://www.underlay.org/:owner/:slug/files → files (latest) +https://www.underlay.org/:owner/:slug/versions → version history +https://www.underlay.org/:owner/:slug/versions/compare?from=v1.0.0&to=v1.1.0 → diff two versions +https://www.underlay.org/:owner/:slug/v/1.2.0 → overview at v1.2.0 +https://www.underlay.org/:owner/:slug/v/1.2.0/records → records at v1.2.0 (?type=TypeName) +https://www.underlay.org/:owner/:slug/v/1.2.0/records/:type/:id → one record at v1.2.0 +https://www.underlay.org/:owner/:slug/v/1.2.0/schemas → schemas at v1.2.0 +https://www.underlay.org/:owner/:slug/v/1.2.0/files → files at v1.2.0 +https://www.underlay.org/records/:hash → a record by hash, with provenance +https://www.underlay.org/schemas/:id → a schema and the collections using it + +Prefer a version-pinned URL when citing data: unpinned URLs follow the latest version. ARK +identifiers (ark:NAAN/.v1.2.0) also resolve to these pages and are the most durable option. + +--- + +## Collection URLs + +Every collection route below is relative to a collection URL, /api/collections/:owner/:slug. +- :owner is the organization's slug (its handle). +- /api/collections/_/:collectionId names the same collection by its id, which never changes; + every route works under it (/api/collections/_/:collectionId/versions/latest/records, ...). +- Every response about a collection you may read carries the header x-underlay-collection: . +- Slugs change, and a released slug can later name another organization. Integrations that store + a reference should store the collection id and use the _/ form. +- Owners named by DID (did:...) or a domain are part of the protocol but not yet resolved here + (404). +These routes are the protocol's read API: https://www.underlay.org/docs/protocol + +--- + +## Reading Data + +Versions are immutable, so a response for a version named by semver or hash may be cached +(anonymous: 10 minutes; members: private). "latest" is not cached. + +### Collections +GET /api/collections → public collections + ?q= (name contains), owner=, tag=, sort=name|records (default: recently updated), + limit= (default 50, max 100), offset=, mine=true (your organizations' collections; session) + → {collections: [...], facets: {owners, tags}, featuredTags, featuredCollections} +GET /api/collections/:owner/:slug → {id, owner: {id, did, handle, name}, slug, name, + description, visibility: public|private, ark, createdAt, updatedAt, versionCount, + head: {semver, hash} | null}, plus public, ownerSlug, ownerName and latestVersion (a summary) +GET /api/accounts/:owner/collections → an organization's collections (members also see + private ones): [{id, slug, name, public, createdAt, updatedAt}] + +### Versions +GET /api/collections/:owner/:slug/versions → [version summary], newest first + (?limit= default 50, max 100; &offset=) +GET /api/collections/:owner/:slug/versions/latest → latest version +GET /api/collections/:owner/:slug/versions/:n → one version (:n = v1.2.0, 1.2.0, ulv2:) + → {semver, major, minor, patch, hash, baseSemver, message, appId, recordCount, fileCount, + totalBytes, typeCounts, createdAt, ark, metadata, schemas: {type: schema}} + Counts are those of the sets you may read. + +### Records +GET .../versions/:n/records → a page of records + ?type= (one type), limit= (default 100, max 2000), cursor= (or after=), offset= + → {records: [{id, type, data, hash, private?}], pagination: {limit, hasMore, nextCursor, total}} +GET .../versions/:n/records/:type/:id → one record: {id, type, data, hash, private?, semver} +GET .../versions/:n/records.ndjson → every record, streamed (see "Bulk Read") +GET .../versions/:n/records.ndjson.gz → the public records as gzip, as stored (?type= for one type) +GET /api/collections/:owner/:slug/records/:type/:id/history + → {type, id, changes: [{seq, semver, createdAt, change: added|updated|removed, hash}], truncated} + the versions where this record changed (the newest 500 versions are examined) + +"private": true marks a record of the private set; only members see those. + +### Manifest (ids and hashes, no bodies) +GET .../versions/:n/manifest + ?limit= (default 10000, max 25000), cursor= + → {semver, hash, schemas: {type: schemaHash}, + records: [{id, type, hash, private?}], + files: [fileHash, ...], (first page only; at most 25,000, then filesTruncated: true) + pagination: {limit, hasMore, nextCursor}} +GET .../versions/:n/manifest?since=v1.0.0 → only the changes since that version (a semver or hash): + {..., since, delta: {added: [{id, type, hash, private?}], + updated: [{id, type, hash, previousHash, private?, previousPrivate?}], + removed: [{id, type, hash, private?}]}, pagination} + Each page holds the next `limit` changes in (type, id) order, divided into the three lists. + Members: an entry in the private set has "private": true (a removal: the set it left). A + record that moved between the sets is under updated, with previousPrivate (present only when + the set changed); a move that kept the hash has previousHash equal to hash. + Non-members see the public set only: a record made private is removed, one made public added. + +Records come in (type, id) order. Cursors are opaque: pass back nextCursor unchanged until hasMore +is false. Manifest entries are about 120 bytes each: the cheapest way to learn what a version holds. + +### Diff +GET .../versions/:n/diff?from=v1.0.0 → records added, updated and removed, with bodies + ?limit= (default 500, max 5000), cursor= + → {from, to, added: [{id, type, data}], updated: [{id, type, data}], removed: [{id, type}], + pagination: {limit, hasMore, nextCursor}, + meta: {schemaChanged, metadataChanged, filesAdded, filesRemoved}} + Without from=, the diff is against the version before :n, which "from" names. The first + version diffs against an empty version: every record is added and "from" is null. + removed carries no hashes; when you need them, use manifest?since= instead. + +### Files +GET .../versions/:n/files → [{hash, size, mimeType, createdAt, referenceCount, references}] + (at most 10,000) +GET /api/collections/:owner/:slug/files/:hash + → 302 to a presigned URL valid for 5 minutes (follow it, e.g. curl -L). A file that was ever + in a public set of a public collection is anonymous; members (key or session) also get + every file the collection holds: the latest version's private files and any file uploaded + to it and verified (earlier versions' private files, uncommitted uploads). The API path is + the durable locator: never store the redirect target. A file you can't read is 404, even + if withheld; 451 if a file you can read is withheld. +HEAD /api/collections/:owner/:slug/files/:hash → 200 if you may read the file through this + collection (the same rule), else 404 or 451 +POST /api/collections/:owner/:slug/files/presign → {"hashes": [...]} (up to 500) + → {hash: presignedUrl | null} +GET /api/collections/files/:hash → 302 to the file from any collection you may read + +:hash is 64 hex characters; a "sha256:" prefix is accepted. + +### Records by hash (across collections) +POST /api/records/batch → {"hashes": [...]} (1 to 100) → NDJSON + {id, type, data, hash}; hashes not found or not + readable are omitted +GET /api/records/:hash/provenance → the collections and versions that hold a record: + {hash, recordId, type, data, size, firstSeen, createdAt, + references: [{owner, collection, collectionName, semver, versionCreatedAt}]} +GET /api/records/:hash/first → where a record or file hash first appeared: + {hash, kind: record|file, owner, collection, semver, createdAt, type?, id?} +Provenance is indexed shortly after each commit, so a just-published version can take a few +seconds to appear. + +### Export +GET /api/collections/:owner/:slug/export → an archive of the latest version + ?version=v2.0.0 (a specific version), format=tar.gz|tar (default tar.gz) + +Archive entries: manifest.json first, README.md if the metadata has a readme, +records/.ndjson (one per type), files/. +manifest.json = {collection: {id, owner, slug, name, description}, + version: {semver, hash, message, recordCount, fileCount, totalBytes, createdAt}, + schemas, files_missing, files_withheld} +The archive streams as it is produced: pipe it to disk or into tar. files_missing lists files +with no stored copy; files_withheld lists files that may not be served. A failure partway +through cuts the response off, so an archive that unpacks without error is complete. Entries +are dated with the version's creation time, so the same version (read with the same access) +exports to byte-identical archives. For bulk record reads, records.ndjson is cheaper. + +### Fork +POST /api/collections/:owner/:slug/fork → {"targetOrg": "my-org", "slug": "optional-new-slug"} + Creates a private collection in targetOrg (you must be a member) whose v1.0.0 is the + source's latest version. A member of the source's organization gets both sets; anyone + else gets the public set only. Nothing is copied: the fork shares the source's storage. + → 201 {id, owner, slug, name, forkedFrom: {owner, slug, version}, version: {semver, recordCount}} + Needs a write or admin key or a session; read keys and collection-confined keys get 403. + 409 if the slug is taken; 422 if the source has no versions. + +--- + +## Writing Data: The Push Flow + +Every push is a DELTA PUSH: open a session against the version you started from, upload the +records you add or change and the (type, id) pairs you delete, then commit. Work and upload are +proportional to the change, not to the collection's size. Any Underlay server accepts the same +exchange: https://www.underlay.org/docs/protocol/push-and-pull + +### Step 1: Get the current head +GET /api/collections/:owner/:slug/versions/latest +→ {semver, hash, recordCount, fileCount, ...} +404 means no versions yet: the first push uses "base": null. + +### Step 2: Work out what changed +If your app tracks its own changes, you already have the upserts and deletes: go to step 3. + +If your app keeps no copy (it exports its whole dataset each time), page through the manifest +(see "Manifest") and compare by (type, id): +- in your data, not in the manifest → upsert +- in both, different hash (see "Record Hashing") → upsert +- in both, same hash, different private flag → upsert with the new flag +- in the manifest, not in your data → delete +Use the manifest's "semver" as the push's "base". + +### Step 3: Open a session +POST /api/collections/:owner/:slug/push +Content-Type: application/json +Authorization: Bearer ul_ + +{ + "base": "v1.2.0", + "message": "Daily archive 2026-04-27", + "app_id": "my-app", + "actor_id": "my-app:cron-job", + "schemas": { + "Article": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "body": {"type": "string"}, + "publishedAt": {"type": "string", "format": "date-time"}, + "authorId": {"type": "string"}, + "pdf": {"type": "object"} + } + }, + "Author": { + "type": "object", + "properties": {"name": {"type": "string"}, "email": {"type": "string"}} + } + }, + "metadata_patch": {"description": "Daily archive of publications"}, + "files": {"add": ["7a8b9c..."]} +} + +Every field is optional: +- base: the semver you diffed against. If it isn't the head: 409 with currentVersion. null or + absent applies your changes to whatever the head is; use null for the first push. +- schemas: the FULL type set, type → JSON Schema. It replaces the base's: a type you leave out + is removed with its records. Omit it to keep the base's types. Every schema of the resulting + set (kept ones too) is checked in full here: draft-07 meta-schema, patterns, $refs that + resolve, and the rules under "Schemas" below. One that fails: 422. +- metadata: replaces the version metadata (description, readme, license, ...). An object or + null; anything else is 400. +- metadata_patch: an object (anything else is 400); merges its top-level fields into the base's + metadata. Ignored when metadata is given. With neither, the metadata is kept. +- files: {"add": [hex, ...], "remove": [hex, ...]}: files to declare or drop beyond those your + records reference. Bare 64-character lowercase hex; other strings are ignored. A declared file + is in the private set (members only), whatever records reference it, and stays declared in + later versions until you remove it. Files your records reference need no declaring. +- message, app_id, actor_id: strings recorded with the version. actor_id is shown to members only. +- strip_unknown_fields: true drops top-level data fields the type's schema doesn't list in + "properties" before hashing. Default false: such records are refused (422). + +Response (200): +{ + "session_id": "uuid", + "base": "v1.2.0", + "needed_files": ["7a8b9c..."], + "expires_at": "2026-04-27T13:00:00.000Z", + "limits": { + "open_bytes": 8388608, + "batch_bytes": 16777216, + "batch_lines": 10000, + "session_idle_seconds": 3600, + "open_sessions": 20, + "file_bytes": 33554432 + } +} + +- needed_files: declared files this collection doesn't hold yet (files already uploaded and + verified for it are held). Upload them before the commit. +- limits: this server's limits. Size requests by them rather than hard-coding numbers: + open_bytes (this request's body), batch_bytes and batch_lines (each records or deletes + request), session_idle_seconds (each upload moves expires_at back), open_sessions (sessions + you may have open or committing at once), file_bytes (largest PUT .../files/:hash). + +### Step 4: Upload records and deletes +POST /api/collections/:owner/:slug/push/:sessionId/records +Content-Type: application/x-ndjson +Authorization: Bearer ul_ + +{"id":"article-42","type":"Article","data":{"title":"New","body":"..."}} +{"id":"article-10","type":"Article","data":{"title":"Updated Title","body":"..."}} +{"id":"article-11","type":"Article","data":{"title":"Draft"},"private":true} + +→ {"received": 3} + +POST /api/collections/:owner/:slug/push/:sessionId/deletes +Content-Type: application/x-ndjson + +{"type":"Article","id":"article-3"} + +→ {"received": 1} + +- Send as many requests as you need, each within batch_lines and batch_bytes. An empty body is + a 400. +- Every record line passes the input rules (see "Record Hashing") and its type's schema, and its + type must be in the session's type set. If ANY line fails: 422 with validationErrors (the + first 100 failing lines, each with its 1-based "line"; totalErrors counts all), and nothing + from that request is stored. Fix the lines and resend the request. +- Every delete line passes the same input rules and id and type checks, and its type must be in + the session's type set; failures answer the same way (validationErrors, totalErrors). +- Within a session, the later upload of a (type, id) wins, whether a record or a delete. +- Deleting a pair the base doesn't hold is not an error. +- You don't send hashes: the server canonicalizes and hashes what you upload. + +### Step 5: Upload new files (if any) +PUT /api/collections/:owner/:slug/files/:hash +Content-Type: application/octet-stream +Authorization: Bearer ul_ +Body: raw file bytes (or multipart/form-data with a "file" field) + +→ 201 {"hash", "status": "stored", "size"}; 400 if the bytes don't hash to :hash; 413 over +limits.file_bytes. + +Larger files (up to 5 TiB) go straight to storage: +POST .../files/uploads {"hash", "size", "mimeType"?} + → 201 {id, url, expiresIn}: PUT the bytes to url + → above 5 GiB: {id, partBytes, partCount, parts: [{partNumber, url}], expiresIn}: PUT each part +GET .../files/uploads/:id/parts?from= → the next page of part URLs +POST .../files/uploads/:id/complete {"parts"?: [{partNumber, etag}]} → 202 {id, status: "verifying"} + (parts is required, and non-empty, for a multipart upload: 400 otherwise) +GET .../files/uploads/:id → {id, hash, size, status: pending|verifying|verified|failed, error} +The server hashes the uploaded bytes; the file counts as held once status is "verified". + +Every file a new record references, and every declared file, must be held for the collection +before the commit. + +### Step 6: Commit +POST /api/collections/:owner/:slug/push/:sessionId/commit +Authorization: Bearer ul_ + +→ 201 {"semver": "v1.3.0", "hash": "ulv2:def456...", "recordCount": 1203, "fileCount": 1, + "changes": {"added": 2, "removed": 1, "updated": 1}} + +Background commit: add ?async=true (or the body {"async": true}). The server also commits in the +background when the session uploaded more than 100,000 records, or when a schema change makes it +revalidate more than 100,000 existing records. +→ 202 {"session_id": "uuid", "status": "committing", "poll": "GET /api/collections/:owner/:slug/push/uuid"} + +Poll GET /api/collections/:owner/:slug/push/:sessionId until status is "committed" or "failed" +(or "open" again, with "error", after a "Missing files" refusal; see Step 7): +{ + "session_id": "uuid", + "status": "committed", + "records_received": 3110000, + "expires_at": "...", + "created_at": "...", + "finalize_started_at": "...", + "result": {"semver": "v1.3.0", "hash": "ulv2:def456...", "recordCount": 3110000, "fileCount": 0, "changes": {...}}, + "error": null +} + +On success "result" is exactly the synchronous 201 body; on failure "error" holds the error body. +The version is invisible until committed, and the commit doesn't depend on your connection. + +### Step 7: Handle errors + +Conflict at open (409): {"error": "Version conflict", "currentVersion": "v1.3.0"} +Conflict at commit (409): {"error": "Version conflict", "currentVersion": "v1.3.0"} (someone +published after you opened) +→ Re-read the head (and the manifest, if you diff), then open a new session. + +No changes (409): {"error": "No changes detected", "hash": "ulv2:..."} +→ Nothing to do. + +Session not open (409): {"error": "Session is committed"} (or expired, failed, ...) + +Invalid records (422): +{"error": "Invalid records", "validationErrors": [{"line": 2, "recordId": "...", "type": "...", "errors": ["..."]}], "totalErrors": 1} +→ Fix those lines and resend, or open the session with "strip_unknown_fields": true if the + problem is fields not in the schema. Invalid deletes answer the same shape ("Invalid deletes"). + +Refused schema (422 at open): {"error": ""} → fix the schema and open again. + +Schema change invalidates existing records (422 at commit): {"error": "Schema validation failed", +"validationErrors": [...], "totalErrors": n} + +Missing files (422 at commit): {"error": "Missing files", "filesNeeded": ["abc..."]} (bare hex) +→ The session stays open: upload the listed files, then commit the same session again. After a + background commit, polling shows status "open" with this body in "error". + +Too large (413): a body over open_bytes or batch_bytes, a request over batch_lines, a file over +file_bytes → split it, or use the presigned upload. + +Too many sessions (429): you have open_sessions sessions open or committing → commit or abandon +one (DELETE .../push/:sessionId), or let it expire. + +Storage busy (503): storage cleanup ran during the commit → push again. + +### Session management +GET /api/collections/:owner/:slug/push/:sessionId → session status (and result after a commit) +DELETE /api/collections/:owner/:slug/push/:sessionId → abandon the session + +Status is one of: open (accepting uploads; "error" holds a "Missing files" refusal, if the last +commit had one), committing, committed ("result" holds the version), failed ("error" says why), +expired (idle too long, or abandoned). + +### First push +Set "base" to null, include schemas for all types, upload every record. The first version is +v1.0.0. + +### Semver +The server assigns it from what changed against the base: +- major: a type added or removed, or a schema changed (including making a type private or public) +- minor: otherwise, any record added, removed or changed (moving between public and private counts) +- patch: otherwise (metadata or files only) +A push that changes nothing makes no version (409 "No changes detected"). + +### Metadata only +POST /api/collections/:owner/:slug/metadata {"description": ..., "readme": ..., "license": ...} +merges the fields into the latest version's metadata (null clears one) and publishes a patch +version: 201 {semver, hash, status: "completed"}, or 200 {semver, unchanged: true}. 422 if the +collection has no versions. + +--- + +## Pagination (Records Endpoint) + +GET .../versions/:n/records?limit=2000&cursor= + +- limit: default 100, max 2000. +- cursor (alias after): opaque; pass pagination.nextCursor back unchanged. With ?type=, a bare + record id also works (records after that id). +- offset: works at any depth (a tree seek, not a scan), but a cursor is stable while you walk. +- type: one record type. +- Order: (type, id), with ids in UTF-8 byte order within a type and types in slug order. +- pagination.total: the number of records you may read (with ?type=, of that type). + +To read a whole version, don't page: use records.ndjson. Paging costs one request per page, +1 unit each; the stream is one request at 10 units. + +--- + +## Bulk Read (the fast way to get a whole version) + +GET .../versions/:n/records.ndjson + +Streams every record you may read as NDJSON in one response, from the version's record trees. +Memory is constant on both ends: process each line as it arrives. + +Content-Type: application/x-ndjson +X-Underlay-Record-Count: 3113504 ← lines to expect + +{"id":"arxiv:0704.0001","type":"Preprint","data":{...},"hash":"abc123..."} + +Parameters: +- type: one record type +- after_type + after: resume after the record (after_type, after) and continue through the + later types +- type + after: resume after this id, within that type only +- after with neither type nor after_type, or after_type without after: 400 + +Guarantees: +- Order: types in slug order; within a type, ids in UTF-8 byte order. +- hash is the record's hash, as on the records endpoint. +- Private records are included for members, each line ending "private":true, and absent for + everyone else. +- X-Underlay-Record-Count counts the records of this request (your access, your ?type=, after + the resume point), withheld records included. + +A stream that dies halfway can't report an error: the 200 was already sent. Count the lines and +compare with X-Underlay-Record-Count. To resume, take the type and id of the last complete line +and request ?after_type=&after=: the stream continues to the end. + +records.ndjson.gz is the public records as stored (?type= for one type): concatenated gzip members, +no hash on each line, the cheapest bulk read. Some decoders stop after the first gzip member; +use one that reads them all (gunzip, zlib with multi-member support). + +### When to use which + +- Whole version, one pass → records.ndjson +- A page, or browsing → records?limit=2000&cursor= +- One record → records/:type/:id +- Only ids, types and hashes → manifest +- What changed since a version → manifest?since= +- Records you know the hashes of → POST /api/records/batch (up to 100 per call) +- An archive to keep → export +- A verifiable copy → log + pack (see "Sync") + +--- + +## Sync (verifiable copies) + +GET /api/collections/:owner/:slug/log?after=&limit= (max 1000) + → {collection: collection.json, head: {entryHash, seq, versionHash}, + entries: [signed version log entries]} +GET /api/collections/:owner/:slug/versions/:n/pack?base=&sets=public|all + → application/x-tar of the objects version :n has that base doesn't (sets=all needs + membership; 403 otherwise) + +collection.json = {id, owner: {id, did, handle, name}, slug, name, description, + visibility: public|private, ark: "ark:NAAN/name" | null, keys: [{id, alg, publicKey}]}. +It is stored with the collection's data, so any copy says who owns the collection and whether it +is public. It is rewritten whenever one of these changes. + +A client verifies the log's signatures and hash chain, then checks every object in the pack +against its hash and rebuilds the trees. @underlay/protocol (and the underlay CLI) do this. +Specification: https://www.underlay.org/docs/protocol/repositories + +--- + +## Record Format + +{ + "id": "unique-stable-id", + "type": "TypeName", + "data": { + "title": "Some value", + "authorId": "author-123", + "attachment": {"$file": "sha256:abc123def456..."} + } +} + +- id: a stable string, unique per type within the collection. Use your app's primary key. +- type: groups records by kind ("Article", "Author", "Grant"); each type has one schema. +- data: any JSON value its schema allows. Refer to other records by id; refer to files with + {"$file": "sha256:"}. + +Keep relationships as ids (no joins); readers resolve them with the schema and records together. + +--- + +## Record Hashing + +The server hashes what you upload, so you only need to hash records yourself to diff against a +manifest or to check a record you read. Compute them exactly as below, or every record will look +changed. + +1. Canonicalize `data` with RFC 8785 (JCS): no whitespace, object keys sorted by their UTF-16 + code units, strings and numbers written as JSON.stringify writes them. +2. Build the canonical form as a string, with a fixed envelope: + '{"id":' + JSON.stringify(id) + ',"type":' + JSON.stringify(type) + ',"data":' + JCS(data) + '}' + The private flag is NOT part of the record or its hash. +3. SHA-256 over the UTF-8 bytes of that string, as 64 lowercase hex characters. + +Do NOT build a sorted object and pass it to JSON.stringify: JavaScript lists integer-like keys +("9", "10") first whatever order they were added in, which gives a different hash whenever some +object in data has such a key. + +```javascript +function jcs(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return '[' + value.map(jcs).join(',') + ']'; + const keys = Object.keys(value).sort(); + return '{' + keys.map((k) => JSON.stringify(k) + ':' + jcs(value[k])).join(',') + '}'; +} + +function canonicalRecord(record) { + return '{"id":' + JSON.stringify(record.id) + ',"type":' + JSON.stringify(record.type) + + ',"data":' + jcs(record.data) + '}'; + // Node.js: createHash('sha256').update(canonical).digest('hex') + // Browser: crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)) +} +``` + +(The reference implementation, @underlay/protocol in the Underlay repository, has +hashRecord(id, type, data) → {hash, canonical}; it is not published to npm.) + +Example: + {"id": "article-1", "type": "Article", "data": {"title": "Hello", "body": "World"}} + canonical form: {"id":"article-1","type":"Article","data":{"body":"World","title":"Hello"}} + The data keys are sorted, so any input key order gives the same hash. + +### Input rules +Every record line is checked on its source text. Refused (422, with a code): +- syntax: not JSON +- duplicate_key: an object with a repeated key +- unsafe_integer: an integer literal beyond ±(2^53 - 1) (put large ids in strings) +- lone_surrogate: an unpaired UTF-16 surrogate in a string or key +- too_deep: data nested more than 64 levels +- bad_envelope: not an object, no data, or a non-boolean private +- bad_id: id missing, not a string, empty, or over 1,024 UTF-8 bytes +- bad_type: type missing, empty, over 128 UTF-8 bytes, starting with ".", or containing /, \ or + control characters +- record_too_large: canonical form over 8 MiB +Strings are not Unicode-normalized. Full rules: https://www.underlay.org/docs/protocol/records + +### Schemas +A schema is JSON Schema draft-07 ($schema, if given, must name draft-07), at most 256 KiB +canonical, with each pattern at most 256 characters. Its hash is SHA-256 of JCS(schema). +"private": true is allowed only at a schema's root (a private type). + +--- + +## Schema Discovery + +GET /api/schemas → search schemas (an array) + ?q= (labels and type names), slug=TypeName, label=uri, schema_hash=, + limit= (default 50, max 100), offset= +GET /api/schemas/:id → one schema with its labels and where it is used +GET /api/collections/:owner/:slug/schemas → the latest version's schemas + (?version=v1.2.0; ?raw=true to skip label enrichment) +POST /api/schemas/:id/labels → add a label {"label": "schema.org/Person"} (write or admin key, or session) +DELETE /api/schemas/:id/labels/:label → remove a label (Underlay stewards only: session or + non-read key) + +The collection schemas endpoint adds known labels to each schema as "x-underlay-labels" (opt out +with ?raw=true). + +--- + +## Privacy & Visibility + +A reader sees content only if it passes every level: + 1. Collection: a private collection is a 404 to non-members. + 2. Type: "private": true at the root of the type's schema makes every record of the type private. + 3. Record: "private": true on the uploaded line makes that record private. +Private content is in each version's private set, readable by members of the owning +organization only. There is NO field-level privacy: "private": true on a property is refused. +Put private fields in a private type, or push the whole record as private. + +Private type: +"schemas": { + "Article": {"type": "object", "properties": {"title": {"type": "string"}}}, + "InternalNote": {"type": "object", "private": true, + "properties": {"note": {"type": "string"}, "articleId": {"type": "string"}}} +} +Public readers see only Article; InternalNote is absent from every read, schemas included. + +Private record: +{"id":"article-1","type":"Article","data":{"title":"Public"}} +{"id":"article-2","type":"Article","data":{"title":"Members only"},"private":true} + +Rules to design around: +1. PRIVACY IS PER VERSION. A record you don't upload keeps its set from the base. A record you DO + upload is public unless its line says "private": true, so re-uploading a private record + without the flag publishes it. Read the current flags from the manifest ("private": true, + members only). +2. REDACTION IS FORWARD-ONLY. Making a record private in a new version hides it there. Earlier + versions are immutable and still serve it, and a file that was in any public set stays + downloadable. +3. Making a collection private stops anonymous reads within about 11 minutes (cached responses + expire); making it public takes effect at once. + +How it works: a version root lists the public set (types, record trees, files) in full and the +private set only as a salted commitment. Version hash = "ulv2:" + SHA-256 of the root, so public +readers can verify it and everything public, and learn only whether a private set exists. + +--- + +## Organization and Collection Management + +Organizations: +POST /api/auth/organization/create → {"name", "slug"} +GET /api/auth/organization/list → your organizations +GET /api/accounts/:slug → an organization or user profile (public) +GET /api/accounts/:slug/members → its members (public) +PATCH /api/accounts/:slug → {slug?, displayName?, bio?, website?} (owners: session or admin key) +DELETE /api/accounts/:slug → (owners: session or admin key; 409 while it holds collections, + or for a personal organization) +PATCH /api/accounts/:slug/ark → {"naan": "" | null} (owners and admins: session or admin key) +GET /api/accounts/me → you and your memberships (session or unscoped personal key) +Invitations and member roles are managed under /api/auth/organization/*; accept an invitation +with POST /api/accounts/invitations/accept {"token"}. + +Collections: +POST /api/accounts/:owner/collections → {"slug", "name", "description"?, "public"? (default false)} + → 201 {id, owner, slug, name} +PATCH /api/collections/:owner/:slug → {"name"?, "slug"?, "public"?} (public: owners and admins) +DELETE /api/collections/:owner/:slug → (owners and admins: a session or an admin key) +POST /api/collections/:owner/:slug/transfer → {"targetOrgSlug"} (owners and admins) + +Reporting abuse: POST /api/abuse-reports {"hash"? | "url"?, "reason", "contact"?} (no auth needed). + +Webhooks (owners and admins), under /api/collections/:owner/:slug/webhooks: +GET, POST {url, bumpFilter?: ["major","minor","patch"], enabled?} (the secret is returned once), +PATCH/DELETE /:id, POST /:id/test, GET /:id/deliveries, POST /:id/deliveries/:did/retry. +Each delivery is a POST with headers x-underlay-event, x-underlay-delivery and +x-underlay-signature: sha256=, and the body +{event: "version.created", collection: {owner, slug}, version: {semver, hash, major, minor, +patch, recordCount, fileCount}, bumpType}. + +ARK identifiers: +GET /ark:/[.v1.2.0][/Type/id] → 302 to the page; ?info or ?? for metadata, ?json for JSON +GET /api/ark/resolve?path= → {type: "redirect", url, metadata} or 404 + +--- + +## API Keys + +POST /api/auth/api-key/create → {"name": "my-app", "metadata": {"scope": "write", "collectionIds"?: [""]}, "expiresIn"?: } + The key is returned once: {"key": "ul_...", "id": "..."} +GET /api/auth/api-key/list → {apiKeys: [{id, name, start, permissions, metadata, createdAt, expiresAt}], total} +POST /api/auth/api-key/delete → revoke {"keyId": "..."} → {"success": true} +These routes need a signed-in session (a key can't manage keys), and create keys owned by you. +scope is read (the default), write or admin; see +"Authentication" for what each allows. + +--- + +## Error Codes + +Errors are JSON: {"error": "", "statusCode": , ...}. + +400 — a malformed request (bad body, an empty batch, an invalid parameter) +401 — authentication required, or an invalid or expired key +403 — the key's scope or role doesn't allow it, or another user's push session +404 — not found, or not readable by you (private content is never a 403) +409 — version conflict, no changes, or a session that isn't open +413 — a body, batch or file over the server's limits +422 — records failing the input rules or schema, a refused schema, or missing files at commit +429 — rate limited, or too many push sessions open (Retry-After) +451 — a file or record withheld (a file you couldn't read anyway is 404) +503 — storage busy; retry after Retry-After + +--- + +Full documentation: https://www.underlay.org/docs +Protocol specification: https://www.underlay.org/docs/protocol +Integration guide: https://www.underlay.org/docs/integration +Source code: https://github.com/knowledgefutures/underlay diff --git a/public/logoLight.svg b/packages/web/public/logoLight.svg similarity index 100% rename from public/logoLight.svg rename to packages/web/public/logoLight.svg diff --git a/packages/web/scripts/ssr-smoke.ts b/packages/web/scripts/ssr-smoke.ts new file mode 100644 index 0000000..6acaa44 --- /dev/null +++ b/packages/web/scripts/ssr-smoke.ts @@ -0,0 +1,451 @@ +/** + * SSR smoke test: the built renderPage (dist/server/entry-server.js) behind the + * v2 app on Node, with a temp SQLite file and an in-memory bucket, seeded with an + * org, a public and a private collection, and a version pushed through the push + * API. Renders pages through app.fetch, so loaders reach the API in-process the + * way they do in production. + * + * pnpm --filter @underlay/web build && pnpm --filter @underlay/web smoke + * + * The server's ports are imported from its sources (as packages/server/test/harness.ts + * builds them) because @underlay/server doesn't export them. + */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + ed25519Signer, + generateSigningKey, + memoryStore, + newSalt, +} from '../../protocol/src/index.ts' +import '../../server/src/handlers.ts' +import { createApp } from '../../server/src/app.ts' +import { MemoryCache } from '../../server/src/cache.ts' +import { openNodeDb } from '../../server/src/db/node.ts' +import * as schema from '../../server/src/db/schema.ts' +import { drainSqliteJobs, SqliteJobs } from '../../server/src/jobs.ts' +import type { Ports } from '../../server/src/ports.ts' +import { createStores } from '../../server/src/stores.ts' +// @ts-ignore: the built bundle has no declarations; its source is src/entry-server.tsx. +import { renderPage } from '../dist/server/entry-server.js' + +const dir = await mkdtemp(join(tmpdir(), 'ul-web-smoke-')) +let failures = 0 + +try { + const db = await openNodeDb(`file:${join(dir, 'db.sqlite')}`) + const cache = new MemoryCache() + const signer = await ed25519Signer(await generateSigningKey()) + const ports: Ports = { + db, + stores: createStores(db, cache, { + bucket: memoryStore(), + repoPrefix: 'repo', + internalPrefix: 'internal', + }), + cache, + signer: async () => signer, + jobs: new SqliteJobs(db), + waitUntil: (p) => void p.catch((err) => console.error(err)), + outboundFetch: async () => new Response('ok'), + publicAssets: { store: memoryStore(), baseUrl: 'https://assets.smoke.test' }, + } + const app = createApp(() => ({ + ports, + config: { appUrl: 'http://smoke.test', deployment: 'smoke' }, + // A session cookie naming the user, else anonymous. A cookie (not the server + // tests' x-test-user header) because SSR loaders forward the page's Cookie. + authenticate: async (req) => { + const user = /(?:^|;\s*)test-user=([^;]+)/.exec(req.headers.get('cookie') ?? '')?.[1] + return user ? { userId: user, scope: 'session', collectionIds: null } : null + }, + renderPage, + // KF Auth stand-in: user "steward" is one; nobody else is. + kf: { + profile: async (userId) => + userId === 'steward' ? { name: 'Steward', image: null, role: 'admin' } : null, + role: async (userId) => (userId === 'steward' ? 'admin' : null), + orgs: async () => [], + entitled: async () => false, + defaultOrgId: async () => null, + isInternalCall: () => false, + }, + })) + + // --- Seed: org "org" with member u1, a public and a private collection. + await db.insert(schema.organization).values({ id: 'org1', name: 'Smoke Org', slug: 'org' }) + await db.insert(schema.user).values({ id: 'u1', name: 'Ada', email: 'u1@example.org' }) + await db.insert(schema.member).values({ organizationId: 'org1', userId: 'u1', role: 'owner' }) + await db + .insert(schema.user) + .values({ id: 'steward', name: 'Steward', email: 'steward@example.org' }) + for (const [slug, isPublic] of [ + ['authors', true], + ['secret', false], + ] as const) { + const [c] = await db + .insert(schema.collections) + .values({ + organizationId: 'org1', + slug, + name: slug, + public: isPublic, + privateSalt: newSalt(), + }) + .returning() + await db.insert(schema.placements).values({ + collectionId: c!.id, + locationId: schema.PLATFORM_LOCATION_ID, + role: 'primary', + sets: 'public+private', + }) + } + + const call = (path: string, init: RequestInit = {}, user?: string) => { + const headers = new Headers(init.headers) + if (user) headers.set('cookie', `test-user=${user}`) + return app.fetch(new Request(`http://smoke.test${path}`, { ...init, headers })) + } + + // --- Push v1.0.0 through the push API. + const base = '/api/collections/org/authors' + const Author = { + type: 'object', + properties: { name: { type: 'string' }, born: { type: 'integer' } }, + required: ['name'], + } + let res = await call( + `${base}/push`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + schemas: { Author }, + message: 'First load of authors', + metadata: { readme: '# Authors\n\nPeople who wrote things. ' }, + }), + }, + 'u1', + ) + if (res.status !== 200) throw new Error(`push open: ${res.status} ${await res.text()}`) + const { session_id: sid } = (await res.json()) as { session_id: string } + res = await call( + `${base}/push/${sid}/records`, + { + method: 'POST', + headers: { 'content-type': 'application/x-ndjson' }, + body: [ + { id: 'ada', type: 'Author', data: { name: 'Ada Lovelace', born: 1815 } }, + { id: 'alan', type: 'Author', data: { name: 'Alan Turing', born: 1912 } }, + { id: 'kurt', type: 'Author', data: { name: 'Kurt Gödel' }, private: true }, + ] + .map((r) => JSON.stringify(r)) + .join('\n'), + }, + 'u1', + ) + if (res.status !== 200) throw new Error(`push records: ${res.status} ${await res.text()}`) + res = await call(`${base}/push/${sid}/commit`, { method: 'POST' }, 'u1') + if (res.status !== 201) throw new Error(`push commit: ${res.status} ${await res.text()}`) + const version = (await res.json()) as { semver: string } + // Post-publish jobs (the reference log behind provenance) run from the jobs table. + await drainSqliteJobs(ports) + console.log(`seeded org/authors ${version.semver}`) + + // --- A storage location and a mirror of org/authors that failed, written as + // rows (adding one through the API needs LOCATION_KEY and a reachable bucket). + // After the drain, so no mirror job tries to reach it. u2 is a plain member. + await db.insert(schema.storageLocations).values({ + id: 'loc1', + organizationId: 'org1', + kind: 's3', + name: 'Archive bucket', + endpoint: 'https://s3.example.org', + bucket: 'archive', + prefix: 'ul', + credentials: 'sealed-credentials', + region: 'us-east-1', + status: 'active', + }) + const authors = (await db.select().from(schema.collections)).find((c) => c.slug === 'authors') + await db.insert(schema.placements).values({ + collectionId: authors!.id, + locationId: 'loc1', + role: 'mirror', + sets: 'public', + state: 'error', + lastError: 'Access Denied', + }) + await db.insert(schema.user).values({ id: 'u2', name: 'Bea', email: 'u2@example.org' }) + await db.insert(schema.member).values({ organizationId: 'org1', userId: 'u2', role: 'member' }) + console.log('seeded a storage location and a mirror\n') + + // --- An org logo, uploaded the way the settings page sends it. + const form = new FormData() + const pngBytes = new Uint8Array(64).fill(7) + pngBytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + form.append('avatar', new File([pngBytes], 'logo.png', { type: 'image/png' })) + res = await call('/api/accounts/org/avatar', { method: 'POST', body: form }, 'u1') + if (res.status !== 200) throw new Error(`avatar upload: ${res.status} ${await res.text()}`) + const { avatarUrl } = (await res.json()) as { avatarUrl: string } + console.log(`uploaded a logo: ${avatarUrl}\n`) + + // --- Pages. + // A record hash and a schema id, for the provenance and schema pages. + const page = (await (await call(`${base}/versions/latest/records?type=Author`)).json()) as { + records: { id: string; hash: string }[] + } + const adaHash = page.records.find((r) => r.id === 'ada')!.hash + const schemas = (await (await call(`${base}/schemas`)).json()) as { + schemas?: { schemaId: string }[] + } + const schemaId = schemas.schemas?.[0]?.schemaId ?? '1' + + interface Check { + path: string + status: number + has?: string[] + lacks?: string[] + location?: string + user?: string + } + const checks: Check[] = [ + { + path: '/', + status: 200, + has: [ + 'A protocol for radically accessible structured knowledge', + 'authors', + 'Underlay', + ], + }, + { + path: '/explore', + status: 200, + has: ['Browse public knowledge collections', '>authors<', 'Smoke Org', 'v1.0.0'], + lacks: ['secret'], + }, + { + path: '/org/authors', + status: 200, + has: [ + 'org/authors · Underlay', + 'v1.0.0', + 'People who wrote things.', + '<script>alert(1)</script>', + 'Author', + '2 records', + 'ulv2:', + ], + lacks: [''], + }, + // Members see the private set too. + { path: '/org/authors', status: 200, has: ['3 records'], user: 'u1' }, + { + path: '/org/authors/records', + status: 200, + has: ['Records — org/authors', 'Ada Lovelace', 'Alan Turing', '1815'], + lacks: ['Kurt'], + }, + { + path: '/org/authors/records?type=Author&page=1', + status: 200, + has: ['Ada Lovelace', 'Kurt Gödel'], + user: 'u1', + }, + { + path: '/org/authors/versions', + status: 200, + has: ['Versions — org/authors', 'v1.0.0', 'First load of authors', 'ulv2:'], + }, + { path: '/org/authors/v/1.0.0/records', status: 200, has: ['Ada Lovelace'] }, + // A record's page: its data and history; rows link to it. + { + path: '/org/authors/v/1.0.0/records/Author/ada', + status: 200, + has: ['Ada Lovelace', 'History in this collection', 'added', 'where else it appears'], + }, + { path: '/org/authors/v/1.0.0/records/Author/kurt', status: 404 }, + { path: '/org/authors/v/1.0.0/records/Author/kurt', status: 200, has: ['Kurt'], user: 'u1' }, + { + path: '/org/authors/records?type=Author', + status: 200, + has: ['/org/authors/v/1.0.0/records/Author/ada'], + }, + { path: '/org/authors/v/v1.0.0', status: 302, location: '/org/authors/v/1.0.0' }, + { path: '/org/authors/schemas', status: 200, has: ['Author', 'born', 'integer'] }, + { path: '/org/authors/files', status: 200, has: ['Files — org/authors'] }, + { path: '/org', status: 200, has: ['Smoke Org', 'authors'], lacks: ['secret'] }, + { path: `/records/${adaHash}`, status: 200, has: ['Ada Lovelace', 'org/authors'] }, + { path: '/records/abc123', status: 404 }, + { path: `/schemas/${schemaId}`, status: 200, has: ['born', 'org/authors'] }, + { path: '/schemas', status: 200, has: ['Schemas'] }, + { path: '/protocol', status: 301, location: '/docs/protocol' }, + // The protocol section shares the docs nav. + { + path: '/docs/protocol', + status: 200, + has: ['The Underlay protocol', 'docs-nav', 'Trees and versions', 'Push and pull'], + }, + { path: '/docs/protocol/records', status: 200, has: ['Input rules', 'duplicate_key'] }, + { path: '/docs/protocol/versions', status: 200, has: ['ulv2:', 'Access sets'] }, + { path: '/docs/protocol/repositories', status: 200, has: ['head.json', 'Version log'] }, + { + path: '/docs/protocol/push-and-pull', + status: 200, + has: ['Delta push', 'Clients without a copy'], + }, + { path: '/docs', status: 200 }, + { path: '/docs/api/records', status: 200, has: ['/api/records/batch', 'provenance'] }, + { path: '/docs/api/sync-and-integrations', status: 200, has: ['Tree sync', 'Webhooks'] }, + { path: '/org/secret', status: 404 }, + { path: '/org/secret', status: 200, has: ['secret'], user: 'u1' }, + { path: '/org/nope', status: 404 }, + { path: '/no/such/page/here', status: 404 }, + { path: '/signup', status: 302, location: '/login' }, + { path: '/dashboard', status: 302, location: '/login' }, + { path: '/dashboard', status: 200, has: ['authors', 'secret'], user: 'u1' }, + // Account and org settings (their data comes from /api/accounts/*). + { path: '/settings', status: 200, has: ['Settings · Underlay'], user: 'u1' }, + { path: '/settings', status: 302, location: '/login' }, + { path: '/settings/sessions', status: 200, has: ['Active sessions'], user: 'u1' }, + { path: '/org/settings', status: 200, has: ['Settings — org', 'Smoke Org'], user: 'u1' }, + { path: '/org/settings/members', status: 200, has: ['Members — org'], user: 'u1' }, + { path: '/new-org', status: 200, user: 'u1' }, + { path: '/invitations/accept?token=x', status: 200, has: ['Organization Invitation'] }, + { path: '/report', status: 200, has: ['Report content', 'What is wrong'] }, + // Steward pages: a rail of sections for stewards; everyone else is told no. + { path: '/admin/abuse', status: 200, has: ['only available to admins'], user: 'u1' }, + { + path: '/admin', + status: 200, + has: ['only available to admins'], + lacks: ['Holdings'], + user: 'u1', + }, + { path: '/superadmin', status: 301, location: '/admin' }, + { + path: '/admin', + status: 200, + has: ['Holdings', 'Needs attention', 'Organizations', 'Corpus', 'Billing', 'Operations'], + user: 'steward', + }, + { path: '/admin?days=7', status: 200, has: ['Last 7 days', 'API calls'], user: 'steward' }, + { path: '/admin/orgs', status: 200, has: ['Smoke Org', '/admin/orgs/org'], user: 'steward' }, + { + path: '/admin/orgs/org', + status: 200, + has: ['Smoke Org', 'authors', 'Members (2)', 'u1@example.org'], + user: 'steward', + }, + { path: '/admin/corpus', status: 200, has: ['Record types', 'Author'], user: 'steward' }, + { path: '/admin/billing', status: 200, has: ['Counter health', 'Smoke Org'], user: 'steward' }, + { + path: '/admin/operations', + status: 200, + has: ['Storage locations', 'Archive bucket', 'Tools'], + user: 'steward', + }, + { + path: '/admin/cleanup', + status: 200, + has: ['Waiting to be cleaned', 'Mark and sweep', 'No runs yet'], + user: 'steward', + }, + { path: '/admin/explore', status: 200, has: ['Explore page'], user: 'steward' }, + { path: '/admin/abuse', status: 200, has: ['Open reports'], user: 'steward' }, + // Mirrors in collection settings: status for members, actions for admins. + { + path: '/org/authors/settings', + status: 200, + has: [ + 'Storage and mirrors', + 'Underlay storage', + 'Archive bucket', + 'archive/ul', + 'Access Denied', + '0 of 1', + '1 version behind', + 'Sync now', + 'Remove', + ], + user: 'u1', + }, + { + path: '/org/authors/settings', + status: 200, + has: ['Archive bucket', 'Access Denied'], + lacks: ['Sync now', 'Add mirror'], + user: 'u2', + }, + // The org's settings: the logo it has, and the upload form for owners. + { + path: '/org/settings', + status: 200, + has: [`src="${avatarUrl}"`, 'Replace logo', 'Remove', 'up to 1 MiB'], + user: 'u1', + }, + { path: '/org', status: 200, has: [avatarUrl] }, + // The org's storage page: owners and admins only, credentials never shown. + { + path: '/org/settings/storage', + status: 200, + has: [ + 'Storage locations', + 'Archive bucket', + 'https://s3.example.org · archive/ul', + 'us-east-1', + 'Re-check', + 'Add a storage location', + 'Default mirrors', + 'Add default mirror', + ], + // Restore is gone, and access is always read and write. + lacks: ['sealed-credentials', 'Restore a collection', 'Write only', 'locRegion'], + user: 'u1', + }, + { + path: '/org/settings/storage', + status: 200, + has: ['Only organization owners and admins manage storage.'], + lacks: ['Archive bucket'], + user: 'u2', + }, + { path: '/org/settings/storage', status: 302, location: '/login' }, + ] + + for (const c of checks) { + const r = await call(c.path, {}, c.user) + // React separates adjacent text nodes with ; match the visible text. + const body = (await r.text()).replaceAll('', '') + const problems: string[] = [] + if (r.status !== c.status) problems.push(`status ${r.status}, expected ${c.status}`) + if (c.location && r.headers.get('location') !== c.location) { + problems.push(`location ${r.headers.get('location')}, expected ${c.location}`) + } + if (c.status === 200) { + if (!r.headers.get('content-type')?.startsWith('text/html')) problems.push('not HTML') + // Every rendered page hydrates: the router data and the built client entry. + for (const s of ['window.__staticRouterHydrationData=', '/assets/entry-client-']) { + if (!body.includes(s)) problems.push(`missing ${JSON.stringify(s)}`) + } + } + for (const s of c.has ?? []) + if (!body.includes(s)) problems.push(`missing ${JSON.stringify(s)}`) + for (const s of c.lacks ?? []) if (body.includes(s)) problems.push(`has ${JSON.stringify(s)}`) + const label = `${c.path}${c.user ? ` (as ${c.user})` : ''}` + if (problems.length) { + failures++ + console.log(`FAIL ${label}\n ${problems.join('\n ')}`) + } else { + console.log(`ok ${label} ${r.status} ${body.length} bytes`) + } + } +} finally { + await rm(dir, { recursive: true, force: true }) +} + +console.log(failures ? `\n${failures} failed` : '\nall passed') +process.exit(failures ? 1 : 0) diff --git a/src/App.tsx b/packages/web/src/App.tsx similarity index 56% rename from src/App.tsx rename to packages/web/src/App.tsx index 93c565f..aac9c95 100644 --- a/src/App.tsx +++ b/packages/web/src/App.tsx @@ -2,7 +2,7 @@ import type { LoaderFunctionArgs, RouteObject } from 'react-router' import { RouteErrorBoundary } from '~/components/NotFound' import Root from '~/components/Root' -import { fetchBase, ssrHeaders } from '~/lib/fetch-base' +import { fetchContext } from '~/lib/fetch-base' import { buildDataRoutes } from '~/route-gen' const components = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx') @@ -10,31 +10,30 @@ const dataModules = import.meta.glob<{ loader?: RouteObject['loader'] handle?: unknown middleware?: RouteObject['middleware'] + shouldRevalidate?: RouteObject['shouldRevalidate'] }>('./routes/**/*.data.ts', { eager: true }) -async function rootLoader({ request }: LoaderFunctionArgs) { - const res = await fetch(`${fetchBase(request.url)}/api/context`, { - headers: ssrHeaders(request), - }) - if (!res.ok) { - return { - currentUser: null, - mirrorConfig: { enabled: false, upstream: '', nodeName: '', syncSchedule: '' }, - kfAccountUrl: '', - kfAuthUrl: '', - } - } - return res.json() +function rootLoader({ request }: LoaderFunctionArgs) { + return fetchContext(request) } const NotFound = () => import('~/routes/404').then((m) => ({ Component: m.default })) +// Unmatched paths render the 404 page with a 404 status (the static handler +// reports 200 for a matched splat route unless its loader says otherwise). +function notFoundLoader(): never { + throw new Response('Not Found', { status: 404 }) +} + export const routes: RouteObject[] = [ { id: 'root', Component: Root, ErrorBoundary: RouteErrorBoundary, loader: rootLoader, - children: [...buildDataRoutes(components, dataModules), { path: '*', lazy: NotFound }], + children: [ + ...buildDataRoutes(components, dataModules), + { path: '*', loader: notFoundLoader, lazy: NotFound }, + ], }, ] diff --git a/packages/web/src/components/AdminLayout.tsx b/packages/web/src/components/AdminLayout.tsx new file mode 100644 index 0000000..c5a07ea --- /dev/null +++ b/packages/web/src/components/AdminLayout.tsx @@ -0,0 +1,63 @@ +import BaseLayout from '~/components/BaseLayout' +import SettingsLayout, { type SettingsRailGroup } from '~/components/SettingsLayout' +import { useAppContext } from '~/lib/app-context' + +/** The steward pages, in rail order. */ +export const adminRail: SettingsRailGroup[] = [ + { + heading: 'Instance', + items: [ + { label: 'Overview', to: '/admin' }, + { label: 'Organizations', to: '/admin/orgs', prefix: true }, + { label: 'Corpus', to: '/admin/corpus' }, + ], + }, + { + heading: 'Accounts', + items: [ + { label: 'Billing', to: '/admin/billing' }, + { label: 'Operations', to: '/admin/operations' }, + { label: 'Cleanup', to: '/admin/cleanup' }, + ], + }, + { + heading: 'Content', + items: [ + { label: 'Abuse reports', to: '/admin/abuse' }, + { label: 'Explore page', to: '/admin/explore' }, + ], + }, +] + +/** The shell for every steward page: the admin rail, and a gate for everyone else. */ +export default function AdminLayout({ + title, + description, + children, +}: { + title: string + description?: string + children: React.ReactNode +}) { + const { currentUser } = useAppContext() + if (currentUser?.kfRole !== 'admin') { + return ( + +
+

This page is only available to admins.

+
+
+ ) + } + return ( + + {children} + + ) +} diff --git a/packages/web/src/components/ApiKeysPanel.tsx b/packages/web/src/components/ApiKeysPanel.tsx new file mode 100644 index 0000000..8db2c45 --- /dev/null +++ b/packages/web/src/components/ApiKeysPanel.tsx @@ -0,0 +1,296 @@ +import { type FormEvent, useEffect, useState } from 'react' +import { Link } from 'react-router' + +import { ApiPlayground } from '~/components/ApiPlayground' +import { Alert, Badge, Button, Field, Input, SectionHeading, Select } from '~/components/ui' +import { getScope, isExpired, isExpiringSoon } from '~/lib/api-keys' +import { authClient } from '~/lib/auth-client' +import { formatDate } from '~/lib/format' + +interface Key { + id: string + name: string + start?: string + permissions?: Record + metadata?: { collectionIds?: string[] } + createdAt: string + expiresAt?: string +} + +interface Collection { + id: string + slug: string +} + +const SCOPE_HINTS: Record = { + read: 'Lists and downloads collections, private records included.', + write: 'Also pushes new versions.', + admin: 'Also changes settings, visibility, mirrors and webhooks, and deletes collections.', +} + +/** + * The signed-in user's API keys, a form for a new one (optionally scoped to one + * of `collections`), and the API playground: the user and org key settings pages. + * Keys belong to the user. On an org's page (`org`), the list shows the keys + * scoped to that org's collections, then the user's keys for every collection. + */ +export default function ApiKeysPanel({ + owner, + collections, + canManage = true, + org = false, +}: { + /** The account the playground calls and whose collections a key can be scoped to. */ + owner: string + collections: Collection[] + /** Create and revoke (org owners and admins on an org's page). */ + canManage?: boolean + /** An org's settings page rather than the user's own. */ + org?: boolean +}) { + const [keys, setKeys] = useState([]) + const [error, setError] = useState('') + const [newKeyResult, setNewKeyResult] = useState<{ key: string; name: string } | null>(null) + const [submitting, setSubmitting] = useState(false) + const [copied, setCopied] = useState(false) + + const [keyLabel, setKeyLabel] = useState('') + const [keyScope, setKeyScope] = useState('write') + const [keyCollectionId, setKeyCollectionId] = useState('') + const [keyExpiresIn, setKeyExpiresIn] = useState('') + + async function loadKeys() { + const { data } = await authClient.apiKey.list() + if (!data) return + const list: Key[] = (data as any).apiKeys ?? (Array.isArray(data) ? data : []) + setKeys([...list].sort((a, b) => b.createdAt.localeCompare(a.createdAt))) + } + + useEffect(() => { + loadKeys() + }, []) + + async function handleCreateKey(e: FormEvent) { + e.preventDefault() + setError('') + setNewKeyResult(null) + setSubmitting(true) + try { + const metadata: Record = { scope: keyScope } + if (keyCollectionId) metadata.collectionIds = [keyCollectionId] + const { data, error: err } = await authClient.apiKey.create({ + name: keyLabel, + metadata, + expiresIn: keyExpiresIn ? parseInt(keyExpiresIn) * 24 * 60 * 60 : undefined, + } as any) + if (err) { + setError(err.message ?? 'Failed to create key.') + } else if (data) { + setNewKeyResult({ key: (data as any).key, name: keyLabel }) + setKeyLabel('') + await loadKeys() + } + } finally { + setSubmitting(false) + } + } + + async function handleRevokeKey(keyId: string) { + setError('') + await authClient.apiKey.delete({ keyId } as any) + setKeys((prev) => prev.filter((k) => k.id !== keyId)) + } + + const ids = new Set(collections.map((c) => c.id)) + const orgKeys = keys.filter((k) => k.metadata?.collectionIds?.some((id) => ids.has(id))) + const allKeys = keys.filter((k) => !k.metadata?.collectionIds?.length) + + const keyList = (keys: Key[], empty: string) => + keys.length === 0 ? ( +

{empty}

+ ) : ( +
+ {keys.map((k) => ( +
+
+
+ {k.name} + {k.start && {k.start}…} +
+
+ {getScope(k.permissions)} + {k.metadata?.collectionIds?.length && ( + + {k.metadata.collectionIds + .map((id) => collections.find((c) => c.id === id)?.slug ?? id.slice(0, 8)) + .join(', ')} + + )} + Created {formatDate(k.createdAt)} + {k.expiresAt && !isExpired(k.expiresAt) && ( + + · Expires {formatDate(k.expiresAt)} + + )} + {isExpired(k.expiresAt ?? null) && ( + · Expired + )} +
+
+ {canManage && ( + + )} +
+ ))} +
+ ) + + return ( + <> + {error && ( + + {error} + + )} + + {newKeyResult && ( + +

Key created: {newKeyResult.name}

+

+ Copy this key now — it won't be shown again. +

+
+ + {newKeyResult.key} + + +
+
+ )} + + {canManage && ( +
+

Create a new key

+
+ + setKeyLabel(e.target.value)} + required + placeholder="ci-deploy" + /> + + + + + + + + + + +
+ +
+ )} + + + Rate limits: Authenticated requests get 5,000 req/min. + Without a key, the API allows 60 req/min per IP. + + Learn more → + + + + {org ? ( + <> + Keys for this organization ({orgKeys.length}) + {keyList(orgKeys, "No keys scoped to this organization's collections.")} + Your keys for all your organizations ({allKeys.length}) +

+ These work on every collection you can reach, here and in your other organizations. + Manage them in{' '} + + account settings + + . +

+ {keyList(allKeys, 'No keys for all collections.')} + + ) : ( + <> + Active keys ({keys.length}) + {keyList(keys, 'No API keys yet.')} + + )} + + {/* API Playground */} +
+ API Playground +

+ Test API calls using your session. Select an endpoint to get started. +

+ ({ id: c.id, slug: c.slug }))} + /> +
+ + ) +} diff --git a/src/components/ApiPlayground.tsx b/packages/web/src/components/ApiPlayground.tsx similarity index 98% rename from src/components/ApiPlayground.tsx rename to packages/web/src/components/ApiPlayground.tsx index a5c2fc6..ce419ba 100644 --- a/src/components/ApiPlayground.tsx +++ b/packages/web/src/components/ApiPlayground.tsx @@ -69,9 +69,9 @@ function getEndpoints(slug: string, collectionSlug: string): Endpoint[] { { label: 'List files', method: 'GET', - path: `/api/collections/${slug}/${collectionSlug}/files`, + path: `/api/collections/${slug}/${collectionSlug}/versions/latest/files`, body: '', - description: 'Returns all files in the latest version.', + description: 'Returns the files in the latest version.', }, ] : []), diff --git a/src/components/BaseLayout.tsx b/packages/web/src/components/BaseLayout.tsx similarity index 59% rename from src/components/BaseLayout.tsx rename to packages/web/src/components/BaseLayout.tsx index d88f5b9..15de37f 100644 --- a/src/components/BaseLayout.tsx +++ b/packages/web/src/components/BaseLayout.tsx @@ -1,50 +1,38 @@ import { Link } from 'react-router' import CreateMenu from '~/components/CreateMenu' +import NavMenu from '~/components/NavMenu' import UserMenu from '~/components/UserMenu' import { useAppContext } from '~/lib/app-context' import { UNDERLAY_UPDATES_URL } from '~/lib/kf-updates' export default function BaseLayout({ children }: { children: React.ReactNode }) { - const { currentUser, mirrorConfig } = useAppContext() + const { currentUser } = useAppContext() const isSteward = currentUser?.kfRole === 'admin' return ( - <> +
-
-
{children}
+
{children}
@@ -74,7 +62,10 @@ export default function BaseLayout({ children }: { children: React.ReactNode }) Knowledge Futures
-
- +
) } diff --git a/src/components/CollectionExplorer.tsx b/packages/web/src/components/CollectionExplorer.tsx similarity index 91% rename from src/components/CollectionExplorer.tsx rename to packages/web/src/components/CollectionExplorer.tsx index b664adf..b2d44d6 100644 --- a/src/components/CollectionExplorer.tsx +++ b/packages/web/src/components/CollectionExplorer.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { Link, useSearchParams } from 'react-router' -import { formatBytesFixed, formatCount, timeAgo } from '~/lib/format' +import { formatBytesFixed, formatCount, tagLabel, timeAgo } from '~/lib/format' interface Collection { id: string @@ -13,8 +13,8 @@ interface Collection { ownerName?: string createdAt: string updatedAt: string - latestVersion: number | null - semver: string | null + /** The head version's semver ("v1.2.0"). */ + latestVersion: string | null recordCount: number | null fileCount: number | null totalBytes: number | null @@ -34,7 +34,15 @@ interface TagFacet { type SortKey = 'featured' | 'updated' | 'name' | 'records' -export default function CollectionExplorer() { +export interface ExploreData { + collections: Collection[] + facets: { owners: OwnerFacet[]; tags?: TagFacet[] } + featuredTags?: string[] + featuredCollections?: Collection[] +} + +/** `initial` is the explore loader's first page, so the list is in the SSR HTML. */ +export default function CollectionExplorer({ initial }: { initial?: ExploreData | null }) { const [searchParams, setSearchParams] = useSearchParams() const initQuery = searchParams.get('q') ?? '' const initOwner = searchParams.get('owner') @@ -46,12 +54,14 @@ export default function CollectionExplorer() { const [selectedOwner, setSelectedOwner] = useState(initOwner) const [selectedTag, setSelectedTag] = useState(initTag) const [sort, setSort] = useState(initSortKey) - const [collections, setCollections] = useState([]) - const [owners, setOwners] = useState([]) - const [tagFacets, setTagFacets] = useState([]) - const [featuredTags, setFeaturedTags] = useState([]) - const [featuredCollections, setFeaturedCollections] = useState([]) - const [loading, setLoading] = useState(true) + const [collections, setCollections] = useState(initial?.collections ?? []) + const [owners, setOwners] = useState(initial?.facets.owners ?? []) + const [tagFacets, setTagFacets] = useState(initial?.facets.tags ?? []) + const [featuredTags, setFeaturedTags] = useState(initial?.featuredTags ?? []) + const [featuredCollections, setFeaturedCollections] = useState( + initial?.featuredCollections ?? [], + ) + const [loading, setLoading] = useState(!initial) const timerRef = useRef>(undefined) const isFiltered = !!(query || selectedOwner || selectedTag) @@ -104,6 +114,8 @@ export default function CollectionExplorer() { // The directive has to sit on the dependency-array line: oxlint reports this rule // against the deps array, not the line its column points at (oxc-project/oxc#18328). useEffect(() => { + // The loader already fetched this list for the URL's filters. + if (initial) return load(initQuery, initOwner, initSortKey, initTag) }, []) // oxlint-disable-line react-hooks/exhaustive-deps @@ -168,7 +180,7 @@ export default function CollectionExplorer() { : 'text-ink-muted hover:bg-parchment-dark/50 hover:text-ink' }`} > - {tag} + {tagLabel(tag)} ))} @@ -315,7 +327,7 @@ export default function CollectionExplorer() { key={tag} className="bg-parchment-dark text-ink-muted rounded-control px-1.5 py-0.5 text-[10px] leading-none" > - {tag} + {tagLabel(tag)} ))} @@ -328,7 +340,7 @@ export default function CollectionExplorer() { )}
- {c.semver && {c.semver}} + {c.latestVersion && {c.latestVersion}} {c.recordCount != null && ( {formatCount(c.recordCount)} rec )} diff --git a/src/components/CreateMenu.tsx b/packages/web/src/components/CreateMenu.tsx similarity index 93% rename from src/components/CreateMenu.tsx rename to packages/web/src/components/CreateMenu.tsx index 80db050..ce92671 100644 --- a/src/components/CreateMenu.tsx +++ b/packages/web/src/components/CreateMenu.tsx @@ -20,7 +20,7 @@ export default function CreateMenu() { onClick={() => setOpen((v) => !v)} aria-expanded={open} aria-haspopup="menu" - className="border-rule bg-parchment-dark hover:bg-rule/30 rounded-control cursor-pointer border px-2.5 py-1 text-xs transition-colors" + className="border-rule bg-parchment-dark hover:bg-rule/30 rounded-control cursor-pointer border px-2.5 py-1 text-xs whitespace-nowrap transition-colors" > New + diff --git a/packages/web/src/components/DocsLayout.tsx b/packages/web/src/components/DocsLayout.tsx new file mode 100644 index 0000000..4107fd3 --- /dev/null +++ b/packages/web/src/components/DocsLayout.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef } from 'react' +import { Link, useLocation } from 'react-router' + +import BaseLayout from '~/components/BaseLayout' +import DocsSearch from '~/components/DocsSearch' +import { docSections, headingSlug } from '~/lib/docs-pages' + +export default function DocsLayout({ + children, + title, + eyebrow, +}: { + children: React.ReactNode + title: string + /** A short line above the title, e.g. the protocol version a page describes. */ + eyebrow?: string +}) { + const location = useLocation() + const currentPath = location.pathname.replace(/\/$/, '') + const prose = useRef(null) + + // Search links to headings by slug: give an h2 without an id its slug, then + // scroll to the linked one (it may not have had an id when the page loaded). + useEffect(() => { + for (const h of prose.current?.querySelectorAll('h2:not([id])') ?? []) { + h.id = headingSlug(h.textContent ?? '') + } + const id = decodeURIComponent(location.hash.slice(1)) + if (id) document.getElementById(id)?.scrollIntoView() + }, [location.pathname, location.hash]) + + return ( + +
+ + +
+ {eyebrow &&

{eyebrow}

} +

{title}

+
+ {children} +
+
+
+
+ ) +} + +/** A code sample in the docs. */ +export function CodeBlock({ children }: { children: string }) { + return ( +
+      {children}
+    
+ ) +} diff --git a/packages/web/src/components/DocsSearch.tsx b/packages/web/src/components/DocsSearch.tsx new file mode 100644 index 0000000..f9c22c8 --- /dev/null +++ b/packages/web/src/components/DocsSearch.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from 'react' +import { Link } from 'react-router' + +import { docSections, headingSlug } from '~/lib/docs-pages' + +const docs = docSections.flatMap((s) => s.pages) + +export default function DocsSearch() { + const [query, setQuery] = useState('') + const [showResults, setShowResults] = useState(false) + const rootRef = useRef(null) + + const q = query.trim().toLowerCase() + + const matches: { title: string; href: string; context?: string }[] = [] + if (q) { + for (const doc of docs) { + const titleMatch = doc.title.toLowerCase().includes(q) + const headingMatches = doc.headings.filter((h) => h.toLowerCase().includes(q)) + if (titleMatch) { + matches.push({ title: doc.title, href: doc.href }) + } + for (const h of headingMatches) { + if (!titleMatch || headingMatches.length > 0) { + const slug = headingSlug(h) + matches.push({ title: doc.title, href: `${doc.href}#${slug}`, context: h }) + } + } + } + } + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setShowResults(false) + } + } + document.addEventListener('click', handleClickOutside) + return () => document.removeEventListener('click', handleClickOutside) + }, []) + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Escape') { + setShowResults(false) + ;(e.target as HTMLInputElement).blur() + } + } + + return ( +
+ { + setQuery(e.target.value) + setShowResults(true) + }} + onKeyDown={handleKeyDown} + /> + {showResults && q && ( +
+ {matches.length === 0 ? ( +
No results
+ ) : ( + matches.slice(0, 12).map((m, i) => ( + + {m.title} + {m.context && {m.context}} + + )) + )} +
+ )} +
+ ) +} diff --git a/src/components/ExploreTagsAdmin.tsx b/packages/web/src/components/ExploreTagsAdmin.tsx similarity index 100% rename from src/components/ExploreTagsAdmin.tsx rename to packages/web/src/components/ExploreTagsAdmin.tsx diff --git a/src/components/FeaturedCollectionsAdmin.tsx b/packages/web/src/components/FeaturedCollectionsAdmin.tsx similarity index 100% rename from src/components/FeaturedCollectionsAdmin.tsx rename to packages/web/src/components/FeaturedCollectionsAdmin.tsx diff --git a/packages/web/src/components/MirrorsSettings.tsx b/packages/web/src/components/MirrorsSettings.tsx new file mode 100644 index 0000000..9f49291 --- /dev/null +++ b/packages/web/src/components/MirrorsSettings.tsx @@ -0,0 +1,331 @@ +import { type FormEvent, useEffect, useState } from 'react' +import { Link } from 'react-router' + +import { Alert, Badge, Button, Select, Table, Td, Th } from '~/components/ui' +import { timeAgo } from '~/lib/format' + +/** + * Where a collection is stored: its primary and any mirrors to the org's + * storage locations, with how far each mirror has copied (packages/server + * src/api/locations.ts). Members see the status; org owners and admins can add + * a mirror, sync one now, or remove one. Mirrors from an org default are + * managed in the org's storage settings. + */ + +export interface Placement { + id: string + role: 'primary' | 'mirror' + sets: 'public' | 'public+private' + inherited: boolean + state: 'active' | 'backfilling' | 'lagging' | 'error' | 'paused' + syncedSeq: number + lag: number + lastError: string | null + updatedAt: string + location: { + id: string + name: string + kind: 'platform' | 's3' + bucket: string | null + prefix: string + status: string + } +} + +export interface PlacementStatus { + headSeq: number + placements: Placement[] +} + +export interface LocationOption { + id: string + name: string + status: string +} + +const STATE_STYLES: Record = { + active: 'text-green-700', + backfilling: 'text-amber-700', + lagging: 'text-amber-700', + error: 'text-red-700', + paused: 'text-ink-muted', +} + +/** States a sync job is working through; the panel polls while any mirror is in one. */ +const MOVING = new Set(['backfilling', 'lagging']) +const POLL_MS = 4000 + +export const SETS_LABELS: Record = { + public: 'Public records', + 'public+private': 'Public and private records', +} + +function where(l: Placement['location']): string { + if (l.kind === 'platform') return 'Underlay storage' + return [l.bucket, l.prefix].filter(Boolean).join('/') +} + +export default function MirrorsSettings({ + owner, + collection, + initial, + locations, + canManage, +}: { + owner: string + collection: string + initial: PlacementStatus + /** The org's storage locations; empty when the viewer can't manage them. */ + locations: LocationOption[] + canManage: boolean +}) { + const base = `/api/collections/${owner}/${collection}/placements` + + const [status, setStatus] = useState(initial) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState('') + const [locationId, setLocationId] = useState('') + const [sets, setSets] = useState('public') + + async function refresh() { + const res = await fetch(base, { credentials: 'include' }) + if (res.ok) setStatus(await res.json()) + } + + const moving = status.placements.some((p) => p.role === 'mirror' && MOVING.has(p.state)) + useEffect(() => { + if (!moving) return + const timer = setInterval(refresh, POLL_MS) + return () => clearInterval(timer) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [moving]) + + /** Run one request, report it, refresh the table; true when it succeeded. */ + async function act(key: string, run: () => Promise, done: string) { + setError('') + setNotice('') + setBusy(key) + try { + const res = await run() + if (res.ok) { + setNotice(done) + await refresh() + return true + } + const body = await res.json().catch(() => ({})) + setError(body.error ?? 'Request failed.') + return false + } finally { + setBusy('') + } + } + + async function handleAdd(e: FormEvent) { + e.preventDefault() + if (!locationId) return + const added = await act( + 'add', + () => + fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ locationId, sets }), + }), + 'Mirror added. Copying existing versions has started.', + ) + if (added) setLocationId('') + } + + const mirrored = new Set(status.placements.map((p) => p.location.id)) + const available = locations.filter((l) => !mirrored.has(l.id)) + + return ( +
+

+ Every version is stored in the primary location. Mirrors copy each version, its files and + its signed version log into a bucket your organization controls, as a complete repository + anyone with access to the bucket can read without Underlay. +

+ + {error && ( + + {error} + + )} + {notice && ( + + {notice} + + )} + + + + + + + + + {canManage && } + + + + {status.placements.map((p) => ( + + + + + + {canManage && ( + + )} + + ))} + +
LocationCopiesStateSynced
+ {p.location.name} {p.role} + {p.inherited && ( + + org default + + )} + + {where(p.location)} + + {SETS_LABELS[p.sets]} + {p.state} + {p.lastError && ( + {p.lastError} + )} + + {p.role === 'primary' ? ( + — + ) : ( + <> + {p.syncedSeq} of {status.headSeq} + {p.lag > 0 && ( + + {p.lag} {p.lag === 1 ? 'version' : 'versions'} behind + + )} + {timeAgo(p.updatedAt)} + + )} + + {p.role === 'mirror' && ( +
+ + {p.inherited ? ( + + Manage in org + + ) : ( + + )} +
+ )} +
+ + {moving && ( +

Copying. This table refreshes on its own.

+ )} + + {canManage && + (locations.length === 0 ? ( +

+ To add a mirror, first add a storage location in the{' '} + + organization's storage settings + + . +

+ ) : available.length === 0 ? ( +

+ This collection mirrors to every storage location the organization has. +

+ ) : ( +
+
+ + +
+
+ + +
+ +
+ ))} +
+ ) +} diff --git a/packages/web/src/components/NavMenu.tsx b/packages/web/src/components/NavMenu.tsx new file mode 100644 index 0000000..97764e8 --- /dev/null +++ b/packages/web/src/components/NavMenu.tsx @@ -0,0 +1,46 @@ +import { useCallback, useRef, useState } from 'react' +import { Link } from 'react-router' + +import { UNDERLAY_UPDATES_URL } from '~/lib/kf-updates' +import { useDismissable } from '~/lib/use-dismissable' + +const item = 'text-ink hover:bg-parchment-dark block px-3 py-2 text-sm transition-colors' + +/** The header's site links in a menu, for narrow screens. */ +export default function NavMenu() { + const [open, setOpen] = useState(false) + const ref = useRef(null) + const close = useCallback(() => setOpen(false), []) + + useDismissable(open, close, ref) + + return ( +
+ ) +} diff --git a/src/components/NotFound.tsx b/packages/web/src/components/NotFound.tsx similarity index 54% rename from src/components/NotFound.tsx rename to packages/web/src/components/NotFound.tsx index 2bdd181..22f0cd4 100644 --- a/src/components/NotFound.tsx +++ b/packages/web/src/components/NotFound.tsx @@ -1,14 +1,8 @@ -import { Component, type ReactNode } from 'react' +import { Component, useEffect, useState, type ReactNode } from 'react' import { isRouteErrorResponse, useRouteError, useRouteLoaderData } from 'react-router' import BaseLayout from '~/components/BaseLayout' - -export class NotFoundError extends Error { - constructor(message?: string) { - super(message) - this.name = 'NotFoundError' - } -} +import { canReloadForStaleBuild, isStaleBuildError, reloadForStaleBuild } from '~/lib/stale-build' export default function NotFound({ message }: { message?: string }) { return ( @@ -24,6 +18,34 @@ export default function NotFound({ message }: { message?: string }) { ) } +/** + * A route's code failed to load because a deploy replaced it (see + * lib/stale-build). Reloads once automatically; if that was just tried, asks. + */ +function StaleBuildNotice() { + const [auto] = useState(canReloadForStaleBuild) + + useEffect(() => { + if (auto) reloadForStaleBuild() + }, [auto]) + + if (auto) return
+ + return ( +
+

Underlay has been updated

+

Reload the page to continue.

+ +
+ ) +} + /** * Route-level error boundary for the data router. Loader-thrown Responses * (404s and friends) land here — without this, react-router renders its @@ -34,27 +56,28 @@ export function RouteErrorBoundary() { // BaseLayout reads the root loader's data; fall back to bare content if it's absent. const rootData = useRouteLoaderData('root') - const content = - isRouteErrorResponse(error) && error.status === 404 ? ( - - ) : ( -
-

- {isRouteErrorResponse(error) ? error.status : 'Error'} -

-

Something went wrong

-

- {isRouteErrorResponse(error) - ? error.statusText - : error instanceof Error - ? error.message - : ''} -

- - ← Back to home - -
- ) + const content = isStaleBuildError(error) ? ( + + ) : isRouteErrorResponse(error) && error.status === 404 ? ( + + ) : ( +
+

+ {isRouteErrorResponse(error) ? error.status : 'Error'} +

+

Something went wrong

+

+ {isRouteErrorResponse(error) + ? error.statusText + : error instanceof Error + ? error.message + : ''} +

+ + ← Back to home + +
+ ) return rootData ? {content} : content } @@ -82,10 +105,10 @@ export class AppErrorBoundary extends Component { } render() { - if (this.state.error instanceof NotFoundError) { + if (this.state.error && isStaleBuildError(this.state.error)) { return ( - + ) } diff --git a/src/components/Root.tsx b/packages/web/src/components/Root.tsx similarity index 100% rename from src/components/Root.tsx rename to packages/web/src/components/Root.tsx diff --git a/src/components/SchemaBrowser.tsx b/packages/web/src/components/SchemaBrowser.tsx similarity index 94% rename from src/components/SchemaBrowser.tsx rename to packages/web/src/components/SchemaBrowser.tsx index 5942f5b..637daa2 100644 --- a/src/components/SchemaBrowser.tsx +++ b/packages/web/src/components/SchemaBrowser.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { Link } from 'react-router' import { Badge } from '~/components/ui' +import { formatDate } from '~/lib/format' interface SchemaResult { id: string @@ -120,13 +121,7 @@ export default function SchemaBrowser() { {isPrivate && private}
- - {new Date(s.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} - + {formatDate(s.createdAt)}
{/* Field summary */} diff --git a/src/components/SchemaLabelManager.tsx b/packages/web/src/components/SchemaLabelManager.tsx similarity index 96% rename from src/components/SchemaLabelManager.tsx rename to packages/web/src/components/SchemaLabelManager.tsx index a1364b7..3c96459 100644 --- a/src/components/SchemaLabelManager.tsx +++ b/packages/web/src/components/SchemaLabelManager.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Button, Input } from '~/components/ui' +import { formatDate } from '~/lib/format' interface Label { label: string @@ -89,10 +90,7 @@ export default function SchemaLabelManager({ schemaId, initialLabels }: Props) { > {l.label} - {new Date(l.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - })} + {formatDate(l.createdAt, 'monthDay')} + ))} + + ) +} + +const CHART_H = 140 +const PAD_TOP = 8 +const AXIS_W = 56 + +/** + * One series as columns: thin bars from a hairline baseline, rounded at the + * data end, a 2px gap between them. Each column's full-height slot is its hover + * and focus target, with the value in a tooltip. + */ +export function ColumnChart({ + points, + format, + label, +}: { + points: { label: string; value: number }[] + format: (n: number) => string + /** What the chart shows, for screen readers. */ + label: string +}) { + const [hover, setHover] = useState(null) + const width = 640 + const plotW = width - AXIS_W + const max = Math.max(1, ...points.map((p) => p.value)) + const slot = plotW / Math.max(1, points.length) + const barW = Math.max(1, Math.min(24, slot - 2)) + const y = (v: number) => PAD_TOP + (CHART_H - PAD_TOP) * (1 - v / max) + const hovered = hover == null ? null : points[hover] + + return ( +
+ setHover(null)} + > + + {format(max)} + + + 0 + + + + {points.map((p, i) => { + const x = AXIS_W + i * slot + (slot - barW) / 2 + const top = y(p.value) + const h = CHART_H - top + const r = Math.min(4, barW / 2, h) + return ( + + {p.value > 0 && ( + + )} + setHover(i)} + onFocus={() => setHover(i)} + onBlur={() => setHover(null)} + className="focus:outline-none" + /> + + ) + })} + {points.length > 0 && ( + <> + + {points[0]!.label} + + {points.length > 1 && ( + + {points.at(-1)!.label} + + )} + + )} + + {hovered && hover != null && ( +
+ {format(hovered.value)}{' '} + {hovered.label} +
+ )} +
+ ) +} + +/** Daily usage as a column chart, with a metric toggle. */ +export function UsageChart({ daily }: { daily: ({ day: string } & Usage)[] }) { + const [metric, setMetric] = useState('api_calls') + return ( + <> + + metricValue(metric, v)} + points={daily.map((d) => ({ label: day(d.day), value: d[metric] }))} + /> + + ) +} + +export interface Column { + key: string + label: string + /** Sort value; the column isn't sortable without one. */ + sort?: (row: T) => number | string + render: (row: T) => React.ReactNode + numeric?: boolean +} + +/** A table whose headers sort it (numbers largest first). */ +export function SortableTable({ + rows, + columns, + rowKey, + initialSort, + footer, +}: { + rows: T[] + columns: Column[] + rowKey: (row: T) => string + initialSort?: string + footer?: React.ReactNode +}) { + const [sortKey, setSortKey] = useState(initialSort ?? null) + const [asc, setAsc] = useState(false) + const col = columns.find((c) => c.key === sortKey) + const sorted = col?.sort + ? [...rows].sort((a, b) => { + const va = col.sort!(a) + const vb = col.sort!(b) + const cmp = + typeof va === 'number' && typeof vb === 'number' + ? va - vb + : String(va).localeCompare(String(vb)) + return asc ? cmp : -cmp + }) + : rows + return ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {sorted.map((row) => ( + + {columns.map((c) => ( + + ))} + + ))} + + {footer && {footer}} +
+ {c.sort ? ( + + ) : ( + c.label + )} +
+ {c.render(row)} +
+
+ ) +} diff --git a/src/components/collection-nav.tsx b/packages/web/src/components/collection-nav.tsx similarity index 88% rename from src/components/collection-nav.tsx rename to packages/web/src/components/collection-nav.tsx index 183bf20..15ddfbe 100644 --- a/src/components/collection-nav.tsx +++ b/packages/web/src/components/collection-nav.tsx @@ -108,6 +108,7 @@ function VersionPicker({ * - Tab row is content: Overview / Records / Schemas / Files / Versions, with * the version picker on its right edge scoping the version-aware tabs * (Overview, Records, Schemas, Files). Versions is the full history. + * On a phone the tabs scroll sideways under the picker's own row. */ export function CollectionNav({ owner, @@ -130,7 +131,8 @@ export function CollectionNav({ }) { // No -mb-px here: inside a scroll container it would overflow vertically by // 1px and make the row scrollable. The wrapper carries the offset instead. - const linkClass = 'px-3 py-2 text-sm font-medium border-b-2 transition-colors' + const linkClass = + 'shrink-0 whitespace-nowrap px-3 py-2 text-sm font-medium border-b-2 transition-colors' const activeClass = `${linkClass} border-ink text-ink` const inactiveClass = `${linkClass} border-transparent text-ink-muted hover:text-ink hover:border-rule` const shareToken = useShareToken() @@ -161,17 +163,20 @@ export function CollectionNav({ return ( <> -
-