From 44d06125b1410bba83868bef8570649243b22525 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Mon, 11 May 2026 14:15:56 -0400 Subject: [PATCH 1/5] first pass test builds --- .github/workflows/ci.yml | 51 +++++ .github/workflows/deploy.yml | 29 ++- package.json | 9 +- pnpm-lock.yaml | 404 +++++++++++++++++++++++++++++++++++ src/lib/ark.test.ts | 247 +++++++++++++++++++++ src/route-gen.test.ts | 54 +++++ vitest.config.ts | 11 + 7 files changed, 787 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/lib/ark.test.ts create mode 100644 src/route-gen.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d66c85 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +run-name: >- + ${{ + github.event_name == 'push' && + format('CI: {0}', github.event.head_commit.message) || + format('CI: {0}', github.sha) + }} + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Lint, typecheck, test + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4f0a9ee..a2d48a0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,20 +2,24 @@ 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 dev: {0}', github.sha) + format('Deploy: {0}', github.sha) }} concurrency: group: >- deploy-${{ github.event_name == 'release' && format('prod-{0}', github.event.release.tag_name) || - format('dev-{0}', github.ref) + github.event_name == 'workflow_run' && format('ci-{0}', github.event.workflow_run.head_branch) || + github.ref }} cancel-in-progress: true on: - push: + workflow_run: + workflows: [CI] + types: [completed] branches: [main] release: types: [published] @@ -39,10 +43,16 @@ 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 @@ -73,19 +83,6 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - name: Set up Node.js and pnpm - 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: diff --git a/package.json b/package.json index 701d850..deb953f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "tool:restore": "tsx tools/restore.ts", "tool:pruneBackups": "tsx tools/pruneBackups.ts", "tool:seed-mirror": "tsx tools/seedMirror.ts", + "test": "vitest run", + "test:watch": "vitest", "secrets:encrypt": "sops -e --input-type dotenv --output-type dotenv --output .env.enc .env", "secrets:encrypt:dev": "sops -e --input-type dotenv --output-type dotenv --output .env.dev.enc .env.dev", "secrets:decrypt": "sops -d --input-type dotenv --output-type dotenv --output .env .env.enc", @@ -57,6 +59,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.0", + "@testing-library/react": "^16.3.2", "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.0", @@ -65,11 +68,13 @@ "@types/react-dom": "^19.1.0", "@types/tar-stream": "^3.1.4", "@vitejs/plugin-react": "^4", - "drizzle-kit": "^0.31.0", "dprint": "latest", + "drizzle-kit": "^0.31.0", + "happy-dom": "^20.9.0", "oxlint": "latest", "tailwindcss": "^4.1.0", "typescript": "^6.0.0", - "vite": "^6" + "vite": "^6", + "vitest": "^4.1.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b29c9b..d17e4a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.0 version: 4.3.0(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 @@ -129,6 +132,9 @@ importers: drizzle-kit: specifier: ^0.31.0 version: 0.31.10 + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 oxlint: specifier: latest version: 1.63.0 @@ -141,6 +147,9 @@ importers: vite: specifier: ^6 version: 6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@25.6.2)(happy-dom@20.9.0)(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)) packages: @@ -374,6 +383,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -1442,6 +1455,9 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -1536,6 +1552,28 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1554,6 +1592,12 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1574,12 +1618,47 @@ packages: '@types/tar-stream@3.1.4': resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1591,6 +1670,21 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -1679,6 +1773,10 @@ packages: caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -1715,10 +1813,17 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dprint@0.54.0: resolution: {integrity: sha512-sIy25poR2gRP/tWPTgP0MPeJoJcpv0xzYDcsboapvthbEt1Qw3Al252CA0xFyIh2cYEGGKyBJtKokryv4ERlJw==} hasBin: true @@ -1829,6 +1934,13 @@ packages: resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==} engines: {node: '>=10.13.0'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -1848,6 +1960,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -1855,6 +1970,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1904,6 +2023,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + hono@4.12.18: resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} engines: {node: '>=16.9.0'} @@ -2019,6 +2142,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2071,6 +2198,9 @@ packages: resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==} engines: {node: '>=6.0.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2088,6 +2218,9 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2109,6 +2242,10 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -2121,6 +2258,9 @@ packages: peerDependencies: react: ^19.2.6 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2173,6 +2313,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -2193,6 +2336,12 @@ packages: sql.js@1.14.1: resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + streamx@2.25.0: resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} @@ -2232,10 +2381,21 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2308,12 +2468,74 @@ packages: yaml: optional: true + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} @@ -2862,6 +3084,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -3713,6 +3937,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3781,6 +4007,29 @@ snapshots: tailwindcss: 4.3.0 vite: 6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.3 @@ -3810,6 +4059,13 @@ snapshots: dependencies: '@types/node': 25.6.2 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/node@25.6.2': @@ -3832,6 +4088,12 @@ snapshots: dependencies: '@types/node': 25.6.2 + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.2 + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 @@ -3844,6 +4106,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -3855,6 +4158,16 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + b4a@1.8.1: {} bare-events@2.8.2: {} @@ -3932,6 +4245,8 @@ snapshots: caniuse-lite@1.0.30001792: {} + chai@6.2.2: {} + chownr@1.1.4: {} codemirror@6.0.2: @@ -3962,8 +4277,12 @@ snapshots: deep-extend@0.6.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + dprint@0.54.0: optionalDependencies: '@dprint/darwin-arm64': 0.54.0 @@ -4003,6 +4322,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@7.0.1: {} + + es-module-lexer@2.1.0: {} + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -4088,6 +4411,10 @@ snapshots: escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -4096,6 +4423,8 @@ snapshots: expand-template@2.0.3: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -4135,6 +4464,18 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.9.0: + dependencies: + '@types/node': 25.6.2 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + hono@4.12.18: {} ieee754@1.2.1: {} @@ -4210,6 +4551,8 @@ snapshots: dependencies: react: 19.2.6 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4242,6 +4585,8 @@ snapshots: nodemailer@8.0.7: {} + obug@2.1.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -4270,6 +4615,8 @@ snapshots: path-expression-matcher@1.5.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -4297,6 +4644,12 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -4314,6 +4667,8 @@ snapshots: react: 19.2.6 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): @@ -4377,6 +4732,8 @@ snapshots: set-cookie-parser@2.7.2: {} + siginfo@2.0.0: {} + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -4396,6 +4753,10 @@ snapshots: sql.js@1.14.1: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + streamx@2.25.0: dependencies: events-universal: 1.0.1 @@ -4458,11 +4819,17 @@ snapshots: transitivePeerDependencies: - react-native-b4a + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -4505,10 +4872,47 @@ snapshots: lightningcss: 1.32.0 tsx: 4.21.0 + vitest@4.1.6(@types/node@25.6.2)(happy-dom@20.9.0)(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.2 + happy-dom: 20.9.0 + transitivePeerDependencies: + - msw + w3c-keyname@2.2.8: {} + whatwg-mimetype@3.0.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrappy@1.0.2: {} + ws@8.20.0: {} + xml-naming@0.1.0: {} yallist@3.1.1: {} diff --git a/src/lib/ark.test.ts b/src/lib/ark.test.ts new file mode 100644 index 0000000..569d625 --- /dev/null +++ b/src/lib/ark.test.ts @@ -0,0 +1,247 @@ +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 { + BETANUMERIC, + BETANUMERIC_CONSONANTS, + buildArkUrl, + buildErc, + collectionToArkId, + computeNcdaCheckChar, + formatErcDate, + nextShoulderCounter, + parseArkPath, +} from '~/lib/ark' + +describe('computeNcdaCheckChar', () => { + test('returns a betanumeric character', () => { + const ch = computeNcdaCheckChar('bcdf') + expect(BETANUMERIC).toContain(ch) + }) + + test('is deterministic', () => { + expect(computeNcdaCheckChar('test123')).toBe(computeNcdaCheckChar('test123')) + }) + + test('different inputs produce different check chars', () => { + const a = computeNcdaCheckChar('abc') + const b = computeNcdaCheckChar('xyz') + // Not guaranteed for all inputs, but these particular ones differ + expect(a).not.toBe(b) + }) +}) + +describe('collectionToArkId', () => { + test('returns a 10-character string', () => { + const id = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') + expect(id).toHaveLength(10) + }) + + test('uses only betanumeric characters', () => { + const id = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') + for (const ch of id) { + expect(BETANUMERIC).toContain(ch) + } + }) + + test('first character is always a consonant', () => { + // Test several UUIDs to exercise the fallback path + const uuids = [ + '550e8400-e29b-41d4-a716-446655440000', + '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + '00000000-0000-0000-0000-000000000000', + 'ffffffff-ffff-ffff-ffff-ffffffffffff', + ] + for (const uuid of uuids) { + const id = collectionToArkId(uuid) + expect(BETANUMERIC_CONSONANTS).toContain(id[0]) + } + }) + + test('is deterministic', () => { + const uuid = '550e8400-e29b-41d4-a716-446655440000' + expect(collectionToArkId(uuid)).toBe(collectionToArkId(uuid)) + }) + + test('different UUIDs produce different IDs', () => { + const a = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') + const b = collectionToArkId('6ba7b810-9dad-11d1-80b4-00c04fd430c8') + expect(a).not.toBe(b) + }) +}) + +describe('nextShoulderCounter', () => { + test('maps 0 to first consonant', () => { + expect(nextShoulderCounter(0)).toBe('b') + }) + + test('maps sequential counts to single consonants', () => { + expect(nextShoulderCounter(1)).toBe('c') + expect(nextShoulderCounter(2)).toBe('d') + }) + + test('maps 18 to last single consonant', () => { + expect(nextShoulderCounter(18)).toBe('z') + }) + + test('wraps to two characters at 19', () => { + expect(nextShoulderCounter(19)).toBe('bb') + expect(nextShoulderCounter(20)).toBe('bc') + }) + + test('uses only consonant characters', () => { + for (let i = 0; i < 50; i++) { + const result = nextShoulderCounter(i) + for (const ch of result) { + expect(BETANUMERIC_CONSONANTS).toContain(ch) + } + } + }) +}) + +describe('parseArkPath', () => { + // Helper: build a valid path from components + function makeValidPath(shoulder: string, arkId: string, version?: number) { + const check = computeNcdaCheckChar(arkId) + let path = `${shoulder}${arkId}${check}` + if (version !== undefined) path += `.v${version}` + return path + } + + test('parses a basic collection ARK', () => { + const arkId = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') + const path = makeValidPath('ulb3', arkId) + const result = parseArkPath(path) + expect(result).toEqual({ + shoulder: 'ulb3', + collectionArkId: arkId, + }) + }) + + test('parses ARK with version suffix', () => { + const arkId = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') + const path = makeValidPath('ulb3', arkId, 5) + const result = parseArkPath(path) + expect(result).toMatchObject({ + shoulder: 'ulb3', + collectionArkId: arkId, + version: 5, + }) + }) + + test('parses ARK with record type and record ID', () => { + const arkId = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') + const check = computeNcdaCheckChar(arkId) + const path = `ulb3${arkId}${check}/Article/rec-001` + const result = parseArkPath(path) + expect(result).toMatchObject({ + shoulder: 'ulb3', + collectionArkId: arkId, + recordType: 'Article', + recordId: 'rec-001', + }) + }) + + test('rejects paths not starting with ul', () => { + expect(parseArkPath('xxb3abcdefghijk')).toBeNull() + }) + + test('rejects paths with invalid check digit', () => { + const arkId = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') + const badCheck = arkId + 'x' // wrong check char (almost certainly) + expect(parseArkPath(`ulb3${badCheck}`)).toBeNull() + }) + + test('rejects version 0', () => { + const arkId = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') + const path = makeValidPath('ulb3', arkId) + '.v0' + // version 0 is replaced by the full arkIdWithCheck since .v0 causes vNum < 1 + expect(parseArkPath(path.replace(makeValidPath('ulb3', arkId), makeValidPath('ulb3', arkId)))).toBeNull + }) + + test('handles multi-character shoulder counters', () => { + const arkId = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') + const path = makeValidPath('ulbc5', arkId) + const result = parseArkPath(path) + expect(result).toMatchObject({ + shoulder: 'ulbc5', + collectionArkId: arkId, + }) + }) +}) + +describe('buildArkUrl', () => { + test('builds a basic ARK URL', () => { + const url = buildArkUrl('12345', 'ulb3', 'bcdfghjkmn') + const check = computeNcdaCheckChar('bcdfghjkmn') + expect(url).toBe(`https://underlay.org/ark:12345/ulb3bcdfghjkmn${check}`) + }) + + test('includes version suffix', () => { + const url = buildArkUrl('12345', 'ulb3', 'bcdfghjkmn', 2) + const check = computeNcdaCheckChar('bcdfghjkmn') + expect(url).toBe(`https://underlay.org/ark:12345/ulb3bcdfghjkmn${check}.v2`) + }) + + test('includes record type and record ID', () => { + const url = buildArkUrl('12345', 'ulb3', 'bcdfghjkmn', undefined, 'Article', 'rec-1') + const check = computeNcdaCheckChar('bcdfghjkmn') + expect(url).toBe(`https://underlay.org/ark:12345/ulb3bcdfghjkmn${check}/Article/rec-1`) + }) + + test('roundtrips with parseArkPath', () => { + const naan = '12345' + const shoulder = 'ulb3' + const arkId = collectionToArkId('550e8400-e29b-41d4-a716-446655440000') + + const url = buildArkUrl(naan, shoulder, arkId, 3, 'Article', 'rec-001') + // Extract the path after "ark:NAAN/" + const pathAfterNaan = url.split(`ark:${naan}/`)[1]! + const parsed = parseArkPath(pathAfterNaan) + + expect(parsed).toMatchObject({ + shoulder, + collectionArkId: arkId, + version: 3, + recordType: 'Article', + recordId: 'rec-001', + }) + }) +}) + +describe('formatErcDate', () => { + test('formats a Date object as YYYYMMDD', () => { + expect(formatErcDate(new Date('2026-05-04T00:00:00Z'))).toBe('20260504') + }) + + test('formats a date string', () => { + expect(formatErcDate('2024-01-15T12:00:00Z')).toBe('20240115') + }) + + test('pads month and day with zeros', () => { + expect(formatErcDate(new Date('2026-01-02T00:00:00Z'))).toBe('20260102') + }) +}) + +describe('buildErc', () => { + test('produces a valid ERC record', () => { + const erc = buildErc({ + type: 'collection', + who: 'Test Author', + what: 'Test Collection', + when: '20260504', + where: 'https://underlay.org/ark:12345/ulb3test', + naan: '12345', + }) + expect(erc).toContain('erc:') + expect(erc).toContain('who: Test Author') + expect(erc).toContain('what: Test Collection') + expect(erc).toContain('when: 20260504') + expect(erc).toContain('where: https://underlay.org/ark:12345/ulb3test') + expect(erc).toContain('erc-support:') + expect(erc).toContain('who: Underlay') + }) +}) diff --git a/src/route-gen.test.ts b/src/route-gen.test.ts new file mode 100644 index 0000000..5d8222a --- /dev/null +++ b/src/route-gen.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'vitest' +import { buildRoutes } from '~/route-gen' + +describe('buildRoutes', () => { + test('converts index files to root path', () => { + const routes = buildRoutes({ + './routes/index.tsx': () => Promise.resolve({}), + }) + expect(routes).toEqual([{ path: '/', filePath: './routes/index.tsx' }]) + }) + + test('converts static page files', () => { + const routes = buildRoutes({ + './routes/about.tsx': () => Promise.resolve({}), + }) + expect(routes).toEqual([{ path: '/about', filePath: './routes/about.tsx' }]) + }) + + test('converts dynamic [param] segments to :param', () => { + const routes = buildRoutes({ + './routes/blog/[slug].tsx': () => Promise.resolve({}), + }) + expect(routes).toEqual([{ path: '/blog/:slug', filePath: './routes/blog/[slug].tsx' }]) + }) + + test('converts nested index files', () => { + const routes = buildRoutes({ + './routes/blog/index.tsx': () => Promise.resolve({}), + }) + expect(routes).toEqual([{ path: '/blog', filePath: './routes/blog/index.tsx' }]) + }) + + test('sorts static segments before dynamic ones', () => { + const routes = buildRoutes({ + './routes/[owner]/index.tsx': () => Promise.resolve({}), + './routes/blog/index.tsx': () => Promise.resolve({}), + './routes/index.tsx': () => Promise.resolve({}), + }) + const paths = routes.map((r) => r.path) + expect(paths).toEqual(['/', '/blog', '/:owner']) + }) + + test('handles multi-level dynamic routes', () => { + const routes = buildRoutes({ + './routes/[owner]/[collection]/index.tsx': () => Promise.resolve({}), + './routes/blog/[slug].tsx': () => Promise.resolve({}), + './routes/dashboard.tsx': () => Promise.resolve({}), + }) + const paths = routes.map((r) => r.path) + // static segments sort alphabetically, then dynamic segments come after + expect(paths.indexOf('/blog/:slug')).toBeLessThan(paths.indexOf('/:owner/:collection')) + expect(paths.indexOf('/dashboard')).toBeLessThan(paths.indexOf('/:owner/:collection')) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3b3e091 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import { resolve } from 'node:path' + +export default defineConfig({ + resolve: { + alias: { '~': resolve(__dirname, 'src') }, + }, + test: { + environment: 'happy-dom', + }, +}) From 552e8c77b8c82337fc41141d7cac58a32c35ce60 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Mon, 11 May 2026 14:17:09 -0400 Subject: [PATCH 2/5] lint --- dprint.json | 6 +- drizzle.config.ts | 12 +- server.ts | 314 ++-- src/App.tsx | 26 +- src/api/accounts.ts | 1173 ++++++++------- src/api/admin.ts | 111 +- src/api/ark-middleware.server.ts | 56 +- src/api/ark.ts | 471 +++--- src/api/auth.server.ts | 98 +- src/api/collections.ts | 489 +++--- src/api/files.ts | 308 ++-- src/api/health.ts | 6 +- src/api/query.ts | 386 ++--- src/api/schemas.ts | 369 +++-- src/api/uploads.ts | 1411 +++++++++--------- src/api/versions.ts | 1251 ++++++++-------- src/components/ApiPlayground.tsx | 325 ++-- src/components/BaseLayout.tsx | 74 +- src/components/BlogLayout.tsx | 18 +- src/components/CollectionExplorer.tsx | 102 +- src/components/DocsLayout.tsx | 46 +- src/components/DocsSearch.tsx | 191 ++- src/components/MirrorAdmin.tsx | 531 +++---- src/components/QueryExplorer.tsx | 1362 +++++++++-------- src/components/SchemaBrowser.tsx | 232 +-- src/components/SchemaLabelManager.tsx | 132 +- src/components/UserMenu.tsx | 79 +- src/db/client.server.ts | 11 +- src/db/migrate.ts | 49 +- src/db/schema.ts | 462 +++--- src/db/seed.ts | 960 ++++++++---- src/entry-client.tsx | 8 +- src/entry-server.tsx | 64 +- src/lib/ark.test.ts | 160 +- src/lib/ark.ts | 208 +-- src/lib/auth.server.ts | 48 +- src/lib/email.ts | 49 +- src/lib/mirror-config.ts | 24 +- src/lib/mirror-sync.ts | 729 ++++----- src/lib/s3.ts | 86 +- src/lib/sqlite-gen.ts | 128 +- src/lib/ssr-data.tsx | 10 +- src/lib/version-helpers.server.ts | 52 +- src/loaders.server.ts | 254 ++-- src/route-gen.test.ts | 54 +- src/route-gen.ts | 34 +- src/routes/[owner]/[collection]/diff.tsx | 335 ++--- src/routes/[owner]/[collection]/index.tsx | 326 ++-- src/routes/[owner]/[collection]/schemas.tsx | 453 +++--- src/routes/[owner]/[collection]/settings.tsx | 267 ++-- src/routes/[owner]/[collection]/v/[n].tsx | 918 ++++++------ src/routes/[owner]/[collection]/versions.tsx | 170 +-- src/routes/[owner]/index.tsx | 307 ++-- src/routes/[owner]/settings/index.tsx | 368 ++--- src/routes/[owner]/settings/keys.tsx | 326 ++-- src/routes/[owner]/settings/members.tsx | 324 ++-- src/routes/admin/mirror.tsx | 12 +- src/routes/blog/[slug].tsx | 30 +- src/routes/blog/index.tsx | 30 +- src/routes/dashboard.tsx | 268 ++-- src/routes/docs/api/accounts.tsx | 119 +- src/routes/docs/api/collections.tsx | 108 +- src/routes/docs/api/files.tsx | 145 +- src/routes/docs/api/index.tsx | 157 +- src/routes/docs/api/versions.tsx | 339 +++-- src/routes/docs/concepts.tsx | 121 +- src/routes/docs/index.tsx | 63 +- src/routes/docs/integration.tsx | 208 ++- src/routes/docs/quickstart.tsx | 40 +- src/routes/docs/self-host.tsx | 153 +- src/routes/explore.tsx | 6 +- src/routes/forgot-password.tsx | 106 +- src/routes/index.tsx | 187 +-- src/routes/invitations/accept.tsx | 165 +- src/routes/login.tsx | 72 +- src/routes/logout.tsx | 8 +- src/routes/query.tsx | 58 +- src/routes/reset-password.tsx | 162 +- src/routes/schemas/[id].tsx | 162 +- src/routes/schemas/index.tsx | 8 +- src/routes/settings/avatar.tsx | 112 +- src/routes/settings/index.tsx | 398 ++--- src/routes/settings/keys.tsx | 264 ++-- src/routes/settings/sessions.tsx | 128 +- src/routes/signup.tsx | 96 +- vite.config.ts | 24 +- vitest.config.ts | 8 +- 87 files changed, 10972 insertions(+), 9548 deletions(-) diff --git a/dprint.json b/dprint.json index c4a6fad..e4c5985 100644 --- a/dprint.json +++ b/dprint.json @@ -8,6 +8,10 @@ "json": { "indentWidth": 2 }, - "includes": ["src/**/*.{ts,tsx}", "server.ts", "vite.config.ts", "drizzle.config.ts"], + "plugins": [ + "https://plugins.dprint.dev/typescript-0.93.3.wasm", + "https://plugins.dprint.dev/json-0.19.4.wasm" + ], + "includes": ["src/**/*.{ts,tsx}", "server.ts", "vite.config.ts", "vitest.config.ts", "drizzle.config.ts"], "excludes": ["node_modules", "dist"] } diff --git a/drizzle.config.ts b/drizzle.config.ts index 2b95d42..36f59a5 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,10 +1,10 @@ -import type { Config } from "drizzle-kit"; +import type { Config, } from 'drizzle-kit' export default { - schema: "./src/db/schema.ts", - out: "./src/db/migrations", - dialect: "postgresql", + schema: './src/db/schema.ts', + out: './src/db/migrations', + dialect: 'postgresql', dbCredentials: { - url: process.env.DATABASE_URL ?? "postgresql://underlay:underlay@localhost:5432/underlay", + url: process.env.DATABASE_URL ?? 'postgresql://underlay:underlay@localhost:5432/underlay', }, -} satisfies Config; +} satisfies Config diff --git a/server.ts b/server.ts index 9337f9c..7d5f827 100644 --- a/server.ts +++ b/server.ts @@ -1,261 +1,261 @@ -import { serve } from '@hono/node-server' -import { Hono } from 'hono' -import { cors } from 'hono/cors' -import { serveStatic } from '@hono/node-server/serve-static' -import { readFileSync, existsSync } from 'node:fs' -import { resolve } from 'node:path' -import { marked } from 'marked' - -import type { AuthEnv } from '~/api/auth.server' -import { authMiddleware, requireAuth } from '~/api/auth.server' -import { arkMiddleware } from '~/api/ark-middleware.server' -import { getMirrorConfig } from '~/lib/mirror-config' -import * as health from '~/api/health' +import { serve, } from '@hono/node-server' +import { serveStatic, } from '@hono/node-server/serve-static' +import { Hono, } from 'hono' +import { cors, } from 'hono/cors' +import { marked, } from 'marked' +import { existsSync, readFileSync, } from 'node:fs' +import { resolve, } from 'node:path' + +import * as accounts from '~/api/accounts' import * as admin from '~/api/admin' -import * as query from '~/api/query' -import * as files from '~/api/files' -import * as schemas from '~/api/schemas' import * as ark from '~/api/ark' +import { arkMiddleware, } from '~/api/ark-middleware.server' +import type { AuthEnv, } from '~/api/auth.server' +import { authMiddleware, requireAuth, } from '~/api/auth.server' import * as collections from '~/api/collections' +import * as files from '~/api/files' +import * as health from '~/api/health' +import * as query from '~/api/query' +import * as schemas from '~/api/schemas' import * as uploads from '~/api/uploads' import * as versions from '~/api/versions' -import * as accounts from '~/api/accounts' +import { getMirrorConfig, } from '~/lib/mirror-config' const isProd = process.env.NODE_ENV === 'production' const app = new Hono() // --- CORS --- -app.use('/api/*', cors({ origin: '*', credentials: true })) +app.use('/api/*', cors({ origin: '*', credentials: true, },),) // --- Auth middleware for API routes --- -app.use('/api/*', authMiddleware) +app.use('/api/*', authMiddleware,) // --- Mirror mode guard for admin routes --- -app.use('/api/admin/*', async (c, next) => { +app.use('/api/admin/*', async (c, next,) => { const config = getMirrorConfig() if (!config.enabled) { - return c.json({ error: 'Not found', statusCode: 404 }, 404) + return c.json({ error: 'Not found', statusCode: 404, }, 404,) } await next() -}) +},) // --- ARK resolution middleware --- -app.use('/ark\\:*', arkMiddleware) +app.use('/ark\\:*', arkMiddleware,) // --- API routes --- -app.get('/api/health', health.check) +app.get('/api/health', health.check,) // Admin (mirror) -app.get('/api/admin/mirror/status', admin.mirrorStatus) -app.post('/api/admin/mirror/test', admin.mirrorTest) -app.post('/api/admin/mirror/sync', admin.mirrorSync) -app.post('/api/admin/mirror/sync/stop', admin.mirrorSyncStop) -app.get('/api/admin/mirror/sync/progress', admin.mirrorSyncProgress) -app.get('/api/admin/mirror/sync/active', admin.mirrorSyncActive) -app.get('/api/admin/mirror/history', admin.mirrorHistory) +app.get('/api/admin/mirror/status', admin.mirrorStatus,) +app.post('/api/admin/mirror/test', admin.mirrorTest,) +app.post('/api/admin/mirror/sync', admin.mirrorSync,) +app.post('/api/admin/mirror/sync/stop', admin.mirrorSyncStop,) +app.get('/api/admin/mirror/sync/progress', admin.mirrorSyncProgress,) +app.get('/api/admin/mirror/sync/active', admin.mirrorSyncActive,) +app.get('/api/admin/mirror/history', admin.mirrorHistory,) // Query -app.get('/api/query/sqlite/:owner/:slug/:version', query.sqlite) -app.get('/api/query/ddl/:owner/:slug/:version', query.ddl) -app.post('/api/query/generate-sql', query.generateSql) -app.get('/api/query/collections/search', query.searchCollections) -app.get('/api/query/collections/:owner/:slug/versions', query.collectionVersions) +app.get('/api/query/sqlite/:owner/:slug/:version', query.sqlite,) +app.get('/api/query/ddl/:owner/:slug/:version', query.ddl,) +app.post('/api/query/generate-sql', query.generateSql,) +app.get('/api/query/collections/search', query.searchCollections,) +app.get('/api/query/collections/:owner/:slug/versions', query.collectionVersions,) // Schemas -app.get('/api/schemas', schemas.listSchemas) -app.get('/api/schemas/:id', schemas.getSchema) -app.get('/api/collections/:owner/:slug/schemas', schemas.collectionSchemas) -app.post('/api/schemas/:id/labels', requireAuth('write'), schemas.addLabel) -app.delete('/api/schemas/:id/labels/:label', requireAuth('admin'), schemas.removeLabel) +app.get('/api/schemas', schemas.listSchemas,) +app.get('/api/schemas/:id', schemas.getSchema,) +app.get('/api/collections/:owner/:slug/schemas', schemas.collectionSchemas,) +app.post('/api/schemas/:id/labels', requireAuth('write',), schemas.addLabel,) +app.delete('/api/schemas/:id/labels/:label', requireAuth('admin',), schemas.removeLabel,) // ARK -app.get('/api/ark/resolve', ark.resolve) -app.get('/api/collections/:owner/:slug/ark', requireAuth('read'), ark.getArk) -app.patch('/api/collections/:owner/:slug/ark', requireAuth('write'), ark.updateArk) -app.get('/api/collections/:owner/:slug/ark/record-types', requireAuth('read'), ark.getArkRecordTypes) -app.patch('/api/collections/:owner/:slug/ark/record-types', requireAuth('write'), ark.updateArkRecordTypes) -app.patch('/api/accounts/:slug/ark', requireAuth('admin'), ark.updateAccountArk) +app.get('/api/ark/resolve', ark.resolve,) +app.get('/api/collections/:owner/:slug/ark', requireAuth('read',), ark.getArk,) +app.patch('/api/collections/:owner/:slug/ark', requireAuth('write',), ark.updateArk,) +app.get('/api/collections/:owner/:slug/ark/record-types', requireAuth('read',), ark.getArkRecordTypes,) +app.patch('/api/collections/:owner/:slug/ark/record-types', requireAuth('write',), ark.updateArkRecordTypes,) +app.patch('/api/accounts/:slug/ark', requireAuth('admin',), ark.updateAccountArk,) // Collections -app.get('/api/collections', collections.list) -app.post('/api/accounts/:owner/collections', requireAuth('write'), collections.create) -app.get('/api/collections/:owner/:slug', collections.get) -app.patch('/api/collections/:owner/:slug', requireAuth('write'), collections.update) -app.delete('/api/collections/:owner/:slug', requireAuth('admin'), collections.remove) -app.get('/api/accounts/:owner/collections', collections.listByOwner) -app.get('/api/collections/:owner/:slug/export', collections.exportArchive) +app.get('/api/collections', collections.list,) +app.post('/api/accounts/:owner/collections', requireAuth('write',), collections.create,) +app.get('/api/collections/:owner/:slug', collections.get,) +app.patch('/api/collections/:owner/:slug', requireAuth('write',), collections.update,) +app.delete('/api/collections/:owner/:slug', requireAuth('admin',), collections.remove,) +app.get('/api/accounts/:owner/collections', collections.listByOwner,) +app.get('/api/collections/:owner/:slug/export', collections.exportArchive,) // Files -app.on('HEAD', '/api/collections/:owner/:slug/files/:hash', files.headFile) -app.get('/api/collections/:owner/:slug/files/:hash', files.getFile) -app.put('/api/collections/:owner/:slug/files/:hash', requireAuth('write'), files.putFile) +app.on('HEAD', '/api/collections/:owner/:slug/files/:hash', files.headFile,) +app.get('/api/collections/:owner/:slug/files/:hash', files.getFile,) +app.put('/api/collections/:owner/:slug/files/:hash', requireAuth('write',), files.putFile,) // Uploads -app.post('/api/collections/:owner/:slug/versions/upload', requireAuth('write'), uploads.startSession) -app.put('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('write'), uploads.appendBatch) -app.get('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('read'), uploads.getSession) -app.post('/api/collections/:owner/:slug/versions/upload/:sessionId/finalize', requireAuth('write'), uploads.finalize) -app.delete('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('write'), uploads.cancelSession) +app.post('/api/collections/:owner/:slug/versions/upload', requireAuth('write',), uploads.startSession,) +app.put('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('write',), uploads.appendBatch,) +app.get('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('read',), uploads.getSession,) +app.post('/api/collections/:owner/:slug/versions/upload/:sessionId/finalize', requireAuth('write',), uploads.finalize,) +app.delete('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('write',), uploads.cancelSession,) // Versions -app.get('/api/collections/:owner/:slug/versions', versions.list) -app.get('/api/collections/:owner/:slug/versions/latest', versions.latest) -app.get('/api/collections/:owner/:slug/versions/:n', versions.getByNumber) -app.get('/api/collections/:owner/:slug/versions/:n/records', versions.records) -app.get('/api/collections/:owner/:slug/versions/:n/files', versions.files) -app.get('/api/collections/:owner/:slug/versions/:n/manifest', versions.manifest) -app.post('/api/collections/:owner/:slug/versions', requireAuth('write'), versions.push) -app.get('/api/collections/:owner/:slug/versions/:n/diff', versions.diff) +app.get('/api/collections/:owner/:slug/versions', versions.list,) +app.get('/api/collections/:owner/:slug/versions/latest', versions.latest,) +app.get('/api/collections/:owner/:slug/versions/:n', versions.getByNumber,) +app.get('/api/collections/:owner/:slug/versions/:n/records', versions.records,) +app.get('/api/collections/:owner/:slug/versions/:n/files', versions.files,) +app.get('/api/collections/:owner/:slug/versions/:n/manifest', versions.manifest,) +app.post('/api/collections/:owner/:slug/versions', requireAuth('write',), versions.push,) +app.get('/api/collections/:owner/:slug/versions/:n/diff', versions.diff,) // Accounts -app.post('/api/accounts/signup', accounts.signup) -app.post('/api/accounts/login', accounts.login) -app.post('/api/accounts/logout', accounts.logout) -app.get('/api/accounts/me', requireAuth(), accounts.getMe) -app.get('/api/accounts/:slug', accounts.getBySlug) -app.patch('/api/accounts/me', requireAuth(), accounts.updateMe) -app.post('/api/accounts/me/email', requireAuth(), accounts.updateEmail) -app.post('/api/accounts/me/password', requireAuth(), accounts.updatePassword) -app.post('/api/accounts/me/avatar', requireAuth(), accounts.uploadAvatar) -app.get('/api/accounts/me/sessions', requireAuth(), accounts.listSessions) -app.delete('/api/accounts/me/sessions/:sessionId', requireAuth(), accounts.deleteSession) -app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe) -app.post('/api/accounts/forgot-password', accounts.forgotPassword) -app.post('/api/accounts/reset-password', accounts.resetPassword) -app.post('/api/accounts/keys', requireAuth(), accounts.createKey) -app.get('/api/accounts/keys', requireAuth(), accounts.listKeys) -app.delete('/api/accounts/keys/:id', requireAuth(), accounts.deleteKey) -app.post('/api/accounts/:slug/keys', requireAuth(), accounts.createOrgKey) -app.get('/api/accounts/:slug/keys', requireAuth(), accounts.listOrgKeys) -app.delete('/api/accounts/:slug/keys/:id', requireAuth(), accounts.deleteOrgKey) -app.post('/api/accounts/orgs', requireAuth(), accounts.createOrg) -app.get('/api/accounts/:slug/members', requireAuth(), accounts.listMembers) -app.post('/api/accounts/:slug/members', requireAuth(), accounts.addMember) -app.patch('/api/accounts/:slug/members/:userId', requireAuth(), accounts.updateMember) -app.delete('/api/accounts/:slug/members/:userId', requireAuth(), accounts.removeMember) -app.patch('/api/accounts/:slug', requireAuth(), accounts.updateOrg) -app.post('/api/accounts/:slug/avatar', requireAuth(), accounts.uploadOrgAvatar) -app.post('/api/accounts/:slug/invitations', requireAuth(), accounts.createInvitation) -app.get('/api/accounts/:slug/invitations', requireAuth(), accounts.listInvitations) -app.delete('/api/accounts/:slug/invitations/:id', requireAuth(), accounts.deleteInvitation) -app.post('/api/accounts/invitations/accept', requireAuth(), accounts.acceptInvitation) -app.delete('/api/accounts/:slug', requireAuth(), accounts.deleteOrg) +app.post('/api/accounts/signup', accounts.signup,) +app.post('/api/accounts/login', accounts.login,) +app.post('/api/accounts/logout', accounts.logout,) +app.get('/api/accounts/me', requireAuth(), accounts.getMe,) +app.get('/api/accounts/:slug', accounts.getBySlug,) +app.patch('/api/accounts/me', requireAuth(), accounts.updateMe,) +app.post('/api/accounts/me/email', requireAuth(), accounts.updateEmail,) +app.post('/api/accounts/me/password', requireAuth(), accounts.updatePassword,) +app.post('/api/accounts/me/avatar', requireAuth(), accounts.uploadAvatar,) +app.get('/api/accounts/me/sessions', requireAuth(), accounts.listSessions,) +app.delete('/api/accounts/me/sessions/:sessionId', requireAuth(), accounts.deleteSession,) +app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe,) +app.post('/api/accounts/forgot-password', accounts.forgotPassword,) +app.post('/api/accounts/reset-password', accounts.resetPassword,) +app.post('/api/accounts/keys', requireAuth(), accounts.createKey,) +app.get('/api/accounts/keys', requireAuth(), accounts.listKeys,) +app.delete('/api/accounts/keys/:id', requireAuth(), accounts.deleteKey,) +app.post('/api/accounts/:slug/keys', requireAuth(), accounts.createOrgKey,) +app.get('/api/accounts/:slug/keys', requireAuth(), accounts.listOrgKeys,) +app.delete('/api/accounts/:slug/keys/:id', requireAuth(), accounts.deleteOrgKey,) +app.post('/api/accounts/orgs', requireAuth(), accounts.createOrg,) +app.get('/api/accounts/:slug/members', requireAuth(), accounts.listMembers,) +app.post('/api/accounts/:slug/members', requireAuth(), accounts.addMember,) +app.patch('/api/accounts/:slug/members/:userId', requireAuth(), accounts.updateMember,) +app.delete('/api/accounts/:slug/members/:userId', requireAuth(), accounts.removeMember,) +app.patch('/api/accounts/:slug', requireAuth(), accounts.updateOrg,) +app.post('/api/accounts/:slug/avatar', requireAuth(), accounts.uploadOrgAvatar,) +app.post('/api/accounts/:slug/invitations', requireAuth(), accounts.createInvitation,) +app.get('/api/accounts/:slug/invitations', requireAuth(), accounts.listInvitations,) +app.delete('/api/accounts/:slug/invitations/:id', requireAuth(), accounts.deleteInvitation,) +app.post('/api/accounts/invitations/accept', requireAuth(), accounts.acceptInvitation,) +app.delete('/api/accounts/:slug', requireAuth(), accounts.deleteOrg,) // --- Blog content API (serves rendered markdown) --- -app.get('/api/blog/:slug', (c) => { - const slug = c.req.param('slug') - const mdPath = resolve('content/blog', `${slug}.md`) - if (!existsSync(mdPath)) { - return c.json({ error: 'Not found' }, 404) +app.get('/api/blog/:slug', (c,) => { + const slug = c.req.param('slug',) + const mdPath = resolve('content/blog', `${slug}.md`,) + if (!existsSync(mdPath,)) { + return c.json({ error: 'Not found', }, 404,) } - const raw = readFileSync(mdPath, 'utf-8') + const raw = readFileSync(mdPath, 'utf-8',) // Strip frontmatter - const fmEnd = raw.indexOf('---', 4) - const body = fmEnd > 0 ? raw.slice(fmEnd + 3).trim() : raw - const html = marked(body) - return c.html(typeof html === 'string' ? html : '') -}) + const fmEnd = raw.indexOf('---', 4,) + const body = fmEnd > 0 ? raw.slice(fmEnd + 3,).trim() : raw + const html = marked(body,) + return c.html(typeof html === 'string' ? html : '',) +},) // API 404 catch-all -app.all('/api/*', (c) => { - return c.json({ error: 'API route not found', statusCode: 404 }, 404) -}) +app.all('/api/*', (c,) => { + return c.json({ error: 'API route not found', statusCode: 404, }, 404,) +},) // --- SSR --- if (isProd) { // Verify SSR build artifacts exist at startup (fail fast, don't wait for first request) - const clientHtml = resolve('dist/client/index.html') - const ssrBundle = resolve('dist/server/entry-server.js') - if (!existsSync(clientHtml)) throw new Error(`Missing ${clientHtml} — did 'pnpm build' run?`) - if (!existsSync(ssrBundle)) throw new Error(`Missing ${ssrBundle} — did 'pnpm build' run?`) + const clientHtml = resolve('dist/client/index.html',) + const ssrBundle = resolve('dist/server/entry-server.js',) + if (!existsSync(clientHtml,)) throw new Error(`Missing ${clientHtml} — did 'pnpm build' run?`,) + if (!existsSync(ssrBundle,)) throw new Error(`Missing ${ssrBundle} — did 'pnpm build' run?`,) // Serve static assets from Vite build output - app.use('/assets/*', serveStatic({ root: './dist/client' })) - app.use('/favicon.svg', serveStatic({ root: './dist/client' })) + app.use('/assets/*', serveStatic({ root: './dist/client', },),) + app.use('/favicon.svg', serveStatic({ root: './dist/client', },),) // Run migrations on startup - const { runMigrations } = await import('~/db/migrate') + const { runMigrations, } = await import('~/db/migrate') await runMigrations() - const template = readFileSync(clientHtml, 'utf-8') - const { render } = await import(ssrBundle as string) + const template = readFileSync(clientHtml, 'utf-8',) + const { render, } = await import(ssrBundle as string) - app.get('*', async (c) => { - const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw) + app.get('*', async (c,) => { + const { html, ssrData, redirect, statusCode, title, description, } = await render(c.req.raw,) if (redirect) { - return c.redirect(redirect, 302) + return c.redirect(redirect, 302,) } let page = template - .replace('', html) + .replace('', html,) .replace( '', - ``, + ``, ) if (title) { - page = page.replace('Underlay', `${title}`) + page = page.replace('Underlay', `${title}`,) } if (description) { page = page.replace( '', - `\n`, + `\n`, ) } - return c.html(page, statusCode ?? 200) - }) + return c.html(page, statusCode ?? 200,) + },) } else { - const { createServer: createViteServer } = await import('vite') + const { createServer: createViteServer, } = await import('vite') const vite = await createViteServer({ - server: { middlewareMode: true }, + server: { middlewareMode: true, }, appType: 'custom', - }) + },) // Vite's Connect middleware for HMR and asset transforms - app.use('*', async (c, next) => { + app.use('*', async (c, next,) => { const nodeReq = (c.env as any).incoming const nodeRes = (c.env as any).outgoing if (!nodeReq || !nodeRes) return next() - return new Promise((resolve) => { - vite.middlewares(nodeReq, nodeRes, () => resolve(next())) - }) - }) + return new Promise((resolve,) => { + vite.middlewares(nodeReq, nodeRes, () => resolve(next(),),) + },) + },) - app.get('*', async (c) => { + app.get('*', async (c,) => { const url = c.req.url - let template = readFileSync(resolve('index.html'), 'utf-8') - template = await vite.transformIndexHtml(url, template) + let template = readFileSync(resolve('index.html',), 'utf-8',) + template = await vite.transformIndexHtml(url, template,) - const { render } = await vite.ssrLoadModule('/src/entry-server.tsx') - const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw) + const { render, } = await vite.ssrLoadModule('/src/entry-server.tsx',) + const { html, ssrData, redirect, statusCode, title, description, } = await render(c.req.raw,) if (redirect) { - return c.redirect(redirect, 302) + return c.redirect(redirect, 302,) } let page = template - .replace('', html) + .replace('', html,) .replace( '', - ``, + ``, ) if (title) { - page = page.replace('Underlay', `${title}`) + page = page.replace('Underlay', `${title}`,) } if (description) { page = page.replace( '', - `\n`, + `\n`, ) } - return c.html(page, statusCode ?? 200) - }) + return c.html(page, statusCode ?? 200,) + },) } -const port = Number(process.env.PORT) || 3000 -console.log(`Server running at http://localhost:${port}`) -serve({ fetch: app.fetch, port }) +const port = Number(process.env.PORT,) || 3000 +console.log(`Server running at http://localhost:${port}`,) +serve({ fetch: app.fetch, port, },) diff --git a/src/App.tsx b/src/App.tsx index 8c04bd2..b042a46 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,29 +1,27 @@ -import { lazy, Suspense } from 'react' -import { Routes, Route } from 'react-router' -import { buildRoutes } from '~/route-gen' +import { lazy, Suspense, } from 'react' +import { Route, Routes, } from 'react-router' +import { buildRoutes, } from '~/route-gen' -const modules = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx') -const routes = buildRoutes(modules) +const modules = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx',) +const routes = buildRoutes(modules,) const componentMap = new Map( - routes.map((r) => [ + routes.map((r,) => [ r.path, - lazy(modules[r.filePath]!), + lazy(modules[r.filePath]!,), ]), ) -export { routes } +export { routes, } export default function App() { return ( - {routes.map((r) => { - const Page = componentMap.get(r.path) - return Page ? ( - } /> - ) : null - })} + {routes.map((r,) => { + const Page = componentMap.get(r.path,) + return Page ? } /> : null + },)} ) diff --git a/src/api/accounts.ts b/src/api/accounts.ts index 2926ce0..eab812e 100644 --- a/src/api/accounts.ts +++ b/src/api/accounts.ts @@ -1,114 +1,131 @@ -import type { Context } from 'hono' -import { getCookie } from 'hono/cookie'; -import { eq, and, count } from 'drizzle-orm'; -import { db, schema } from '../db/client.server.js'; -import bcrypt from 'bcrypt'; -import { v4 as uuidv4 } from 'uuid'; -import { setSessionCookie, clearSessionCookie, type AuthEnv } from './auth.server.js'; -import { uploadToS3, deleteS3Objects, listS3Objects } from '../lib/s3.js'; -import { sendEmail } from '../lib/email.js'; +import bcrypt from 'bcrypt' +import { and, count, eq, } from 'drizzle-orm' +import type { Context, } from 'hono' +import { getCookie, } from 'hono/cookie' +import { v4 as uuidv4, } from 'uuid' +import { db, schema, } from '../db/client.server.js' +import { sendEmail, } from '../lib/email.js' +import { deleteS3Objects, listS3Objects, uploadToS3, } from '../lib/s3.js' +import { type AuthEnv, clearSessionCookie, setSessionCookie, } from './auth.server.js' /** Base URL for public assets (avatars, etc.) */ -const ASSETS_BASE_URL = process.env.ASSETS_BASE_URL ?? "https://assets.underlay.org"; +const ASSETS_BASE_URL = process.env.ASSETS_BASE_URL ?? 'https://assets.underlay.org' const RESERVED_SLUGS = new Set([ - "explore", "docs", "connect", "blog", "dashboard", "settings", - "api", "login", "signup", "admin", "about", "help", "support", - "search", "new", "create", "edit", "delete", "404", "500", -]); + 'explore', + 'docs', + 'connect', + 'blog', + 'dashboard', + 'settings', + 'api', + 'login', + 'signup', + 'admin', + 'about', + 'help', + 'support', + 'search', + 'new', + 'create', + 'edit', + 'delete', + '404', + '500', +],) // Signup -export async function signup(c: Context) { - const { email, password, username, displayName } = await c.req.json(); +export async function signup(c: Context,) { + const { email, password, username, displayName, } = await c.req.json() - if (RESERVED_SLUGS.has(username.toLowerCase())) { - return c.json({ error: "That username is reserved", statusCode: 422 }, 422); + if (RESERVED_SLUGS.has(username.toLowerCase(),)) { + return c.json({ error: 'That username is reserved', statusCode: 422, }, 422,) } const existing = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, username)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.slug, username,),) + .limit(1,) if (existing.length > 0) { - return c.json({ error: "Username already taken", statusCode: 409 }, 409); + return c.json({ error: 'Username already taken', statusCode: 409, }, 409,) } - const passwordHash = await bcrypt.hash(password, 10); - const id = uuidv4(); + const passwordHash = await bcrypt.hash(password, 10,) + const id = uuidv4() - await db.insert(schema.accounts).values({ + await db.insert(schema.accounts,).values({ id, slug: username, - type: "user", + type: 'user', displayName, email, passwordHash, - }); + },) - const sessionId = uuidv4(); - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days - await db.insert(schema.sessions).values({ + const sessionId = uuidv4() + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000,) // 30 days + await db.insert(schema.sessions,).values({ id: sessionId, userId: id, expiresAt, - userAgent: c.req.header('user-agent') ?? null, - ipAddress: c.req.header('x-forwarded-for') || 'unknown', - }); + userAgent: c.req.header('user-agent',) ?? null, + ipAddress: c.req.header('x-forwarded-for',) || 'unknown', + },) - setSessionCookie(c, sessionId); + setSessionCookie(c, sessionId,) - return c.json({ id, slug: username, displayName }, 201); + return c.json({ id, slug: username, displayName, }, 201,) } // Login -export async function login(c: Context) { - const { email, password } = await c.req.json(); +export async function login(c: Context,) { + const { email, password, } = await c.req.json() - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.email, email)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.email, email,),) + .limit(1,) if (!account?.passwordHash) { - return c.json({ error: "Invalid credentials", statusCode: 401 }, 401); + return c.json({ error: 'Invalid credentials', statusCode: 401, }, 401,) } - const valid = await bcrypt.compare(password, account.passwordHash); + const valid = await bcrypt.compare(password, account.passwordHash,) if (!valid) { - return c.json({ error: "Invalid credentials", statusCode: 401 }, 401); + return c.json({ error: 'Invalid credentials', statusCode: 401, }, 401,) } - const sessionId = uuidv4(); - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); - await db.insert(schema.sessions).values({ + const sessionId = uuidv4() + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000,) + await db.insert(schema.sessions,).values({ id: sessionId, userId: account.id, expiresAt, - userAgent: c.req.header('user-agent') ?? null, - ipAddress: c.req.header('x-forwarded-for') || 'unknown', - }); + userAgent: c.req.header('user-agent',) ?? null, + ipAddress: c.req.header('x-forwarded-for',) || 'unknown', + },) - setSessionCookie(c, sessionId); + setSessionCookie(c, sessionId,) - return c.json({ id: account.id, slug: account.slug, displayName: account.displayName }); + return c.json({ id: account.id, slug: account.slug, displayName: account.displayName, },) } // Logout -export async function logout(c: Context) { - const sessionId = getCookie(c, 'session'); +export async function logout(c: Context,) { + const sessionId = getCookie(c, 'session',) if (sessionId) { - await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)); + await db.delete(schema.sessions,).where(eq(schema.sessions.id, sessionId,),) } - clearSessionCookie(c); - return c.json({ ok: true }); + clearSessionCookie(c,) + return c.json({ ok: true, },) } // Get current user -export async function getMe(c: Context) { - const [account] = await db +export async function getMe(c: Context,) { + const [account,] = await db .select({ id: schema.accounts.id, slug: schema.accounts.slug, @@ -122,13 +139,13 @@ export async function getMe(c: Context) { emailVerified: schema.accounts.emailVerified, notificationPrefs: schema.accounts.notificationPrefs, createdAt: schema.accounts.createdAt, - }) - .from(schema.accounts) - .where(eq(schema.accounts.id, c.get('accountId')!)) - .limit(1); + },) + .from(schema.accounts,) + .where(eq(schema.accounts.id, c.get('accountId',)!,),) + .limit(1,) if (!account) { - return c.json({ error: "Account not found", statusCode: 404 }, 404); + return c.json({ error: 'Account not found', statusCode: 404, }, 404,) } // Fetch org memberships @@ -138,18 +155,18 @@ export async function getMe(c: Context) { role: schema.orgMemberships.role, slug: schema.accounts.slug, displayName: schema.accounts.displayName, - }) - .from(schema.orgMemberships) - .innerJoin(schema.accounts, eq(schema.orgMemberships.orgId, schema.accounts.id)) - .where(eq(schema.orgMemberships.userId, account.id)); + },) + .from(schema.orgMemberships,) + .innerJoin(schema.accounts, eq(schema.orgMemberships.orgId, schema.accounts.id,),) + .where(eq(schema.orgMemberships.userId, account.id,),) - return c.json({ ...account, orgs: memberships }); + return c.json({ ...account, orgs: memberships, },) } // Get account by slug (public) -export async function getBySlug(c: Context) { - const slug = c.req.param('slug')!; - const [account] = await db +export async function getBySlug(c: Context,) { + const slug = c.req.param('slug',)! + const [account,] = await db .select({ id: schema.accounts.id, slug: schema.accounts.slug, @@ -161,147 +178,147 @@ export async function getBySlug(c: Context) { avatarUrl: schema.accounts.avatarUrl, arkNaan: schema.accounts.arkNaan, createdAt: schema.accounts.createdAt, - }) - .from(schema.accounts) - .where(eq(schema.accounts.slug, slug)) - .limit(1); + },) + .from(schema.accounts,) + .where(eq(schema.accounts.slug, slug,),) + .limit(1,) if (!account) { - return c.json({ error: "Account not found", statusCode: 404 }, 404); + return c.json({ error: 'Account not found', statusCode: 404, }, 404,) } // Include ARK shoulder if minted - const [shoulderRow] = await db - .select({ shoulder: schema.arkShoulders.shoulder }) - .from(schema.arkShoulders) - .where(eq(schema.arkShoulders.accountId, account.id)) - .limit(1); + const [shoulderRow,] = await db + .select({ shoulder: schema.arkShoulders.shoulder, },) + .from(schema.arkShoulders,) + .where(eq(schema.arkShoulders.accountId, account.id,),) + .limit(1,) - return c.json({ ...account, arkShoulder: shoulderRow?.shoulder ?? null }); + return c.json({ ...account, arkShoulder: shoulderRow?.shoulder ?? null, },) } // Update own profile -export async function updateMe(c: Context) { - const { displayName, bio, website, location, notificationPrefs } = await c.req.json(); - - const updates: Record = {}; - if (displayName !== undefined) updates.displayName = displayName; - if (bio !== undefined) updates.bio = bio; - if (website !== undefined) updates.website = website; - if (location !== undefined) updates.location = location; - if (notificationPrefs !== undefined) updates.notificationPrefs = notificationPrefs; - - if (Object.keys(updates).length > 0) { - await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, c.get('accountId')!)); +export async function updateMe(c: Context,) { + const { displayName, bio, website, location, notificationPrefs, } = await c.req.json() + + const updates: Record = {} + if (displayName !== undefined) updates.displayName = displayName + if (bio !== undefined) updates.bio = bio + if (website !== undefined) updates.website = website + if (location !== undefined) updates.location = location + if (notificationPrefs !== undefined) updates.notificationPrefs = notificationPrefs + + if (Object.keys(updates,).length > 0) { + await db.update(schema.accounts,).set(updates,).where(eq(schema.accounts.id, c.get('accountId',)!,),) } - return c.json({ ok: true }); + return c.json({ ok: true, },) } // Change email (requires current password) -export async function updateEmail(c: Context) { - const { newEmail, password } = await c.req.json(); +export async function updateEmail(c: Context,) { + const { newEmail, password, } = await c.req.json() - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.id, c.get('accountId')!)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.id, c.get('accountId',)!,),) + .limit(1,) if (!account?.passwordHash) { - return c.json({ error: "Cannot change email for this account type", statusCode: 400 }, 400); + return c.json({ error: 'Cannot change email for this account type', statusCode: 400, }, 400,) } - const valid = await bcrypt.compare(password, account.passwordHash); + const valid = await bcrypt.compare(password, account.passwordHash,) if (!valid) { - return c.json({ error: "Invalid password", statusCode: 401 }, 401); + return c.json({ error: 'Invalid password', statusCode: 401, }, 401,) } // Check email not taken - const [existing] = await db + const [existing,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.email, newEmail)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.email, newEmail,),) + .limit(1,) if (existing && existing.id !== account.id) { - return c.json({ error: "Email already in use", statusCode: 409 }, 409); + return c.json({ error: 'Email already in use', statusCode: 409, }, 409,) } await db - .update(schema.accounts) - .set({ email: newEmail, emailVerified: false }) - .where(eq(schema.accounts.id, c.get('accountId')!)); + .update(schema.accounts,) + .set({ email: newEmail, emailVerified: false, },) + .where(eq(schema.accounts.id, c.get('accountId',)!,),) - return c.json({ ok: true }); + return c.json({ ok: true, },) } // Change password -export async function updatePassword(c: Context) { - const { currentPassword, newPassword } = await c.req.json(); +export async function updatePassword(c: Context,) { + const { currentPassword, newPassword, } = await c.req.json() if (newPassword.length < 8) { - return c.json({ error: "Password must be at least 8 characters", statusCode: 422 }, 422); + return c.json({ error: 'Password must be at least 8 characters', statusCode: 422, }, 422,) } - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.id, c.get('accountId')!)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.id, c.get('accountId',)!,),) + .limit(1,) if (!account?.passwordHash) { - return c.json({ error: "Cannot change password for this account type", statusCode: 400 }, 400); + return c.json({ error: 'Cannot change password for this account type', statusCode: 400, }, 400,) } - const valid = await bcrypt.compare(currentPassword, account.passwordHash); + const valid = await bcrypt.compare(currentPassword, account.passwordHash,) if (!valid) { - return c.json({ error: "Current password is incorrect", statusCode: 401 }, 401); + return c.json({ error: 'Current password is incorrect', statusCode: 401, }, 401,) } - const newHash = await bcrypt.hash(newPassword, 10); + const newHash = await bcrypt.hash(newPassword, 10,) await db - .update(schema.accounts) - .set({ passwordHash: newHash }) - .where(eq(schema.accounts.id, c.get('accountId')!)); + .update(schema.accounts,) + .set({ passwordHash: newHash, },) + .where(eq(schema.accounts.id, c.get('accountId',)!,),) - return c.json({ ok: true }); + return c.json({ ok: true, },) } // Upload avatar -export async function uploadAvatar(c: Context) { - const body = await c.req.parseBody(); - const file = Object.values(body).find((v): v is File => v instanceof File); +export async function uploadAvatar(c: Context,) { + const body = await c.req.parseBody() + const file = Object.values(body,).find((v,): v is File => v instanceof File) if (!file) { - return c.json({ error: "No file uploaded", statusCode: 400 }, 400); + return c.json({ error: 'No file uploaded', statusCode: 400, }, 400,) } - const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; - if (!allowedTypes.includes(file.type)) { - return c.json({ error: "Only JPEG, PNG, GIF, and WebP images are allowed", statusCode: 422 }, 422); + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp',] + if (!allowedTypes.includes(file.type,)) { + return c.json({ error: 'Only JPEG, PNG, GIF, and WebP images are allowed', statusCode: 422, }, 422,) } - const buffer = Buffer.from(await file.arrayBuffer()); + const buffer = Buffer.from(await file.arrayBuffer(),) if (buffer.length > 5 * 1024 * 1024) { - return c.json({ error: "Image must be less than 5MB", statusCode: 422 }, 422); + return c.json({ error: 'Image must be less than 5MB', statusCode: 422, }, 422,) } - const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]; - const accountId = c.get('accountId')!; - const key = `avatars/${accountId}/${Date.now()}.${ext}`; + const ext = file.type.split('/',)[1] === 'jpeg' ? 'jpg' : file.type.split('/',)[1] + const accountId = c.get('accountId',)! + const key = `avatars/${accountId}/${Date.now()}.${ext}` - await uploadToS3(key, buffer, file.type); + await uploadToS3(key, buffer, file.type,) await db - .update(schema.accounts) - .set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` }) - .where(eq(schema.accounts.id, accountId)); + .update(schema.accounts,) + .set({ avatarUrl: `${ASSETS_BASE_URL}/${key}`, },) + .where(eq(schema.accounts.id, accountId,),) - return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }); + return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}`, },) } // List sessions -export async function listSessions(c: Context) { +export async function listSessions(c: Context,) { const sessions = await db .select({ id: schema.sessions.id, @@ -309,201 +326,203 @@ export async function listSessions(c: Context) { ipAddress: schema.sessions.ipAddress, createdAt: schema.sessions.createdAt, expiresAt: schema.sessions.expiresAt, - }) - .from(schema.sessions) - .where(eq(schema.sessions.userId, c.get('accountId')!)); + },) + .from(schema.sessions,) + .where(eq(schema.sessions.userId, c.get('accountId',)!,),) // Get current session ID to mark it - const currentSessionId = getCookie(c, 'session'); - return c.json(sessions.map((s) => ({ + const currentSessionId = getCookie(c, 'session',) + return c.json(sessions.map((s,) => ({ ...s, current: s.id === currentSessionId, - }))); + })),) } // Revoke a session -export async function deleteSession(c: Context) { - const sessionId = c.req.param('sessionId')!; +export async function deleteSession(c: Context,) { + const sessionId = c.req.param('sessionId',)! - const [session] = await db + const [session,] = await db .select() - .from(schema.sessions) - .where(and(eq(schema.sessions.id, sessionId), eq(schema.sessions.userId, c.get('accountId')!))) - .limit(1); + .from(schema.sessions,) + .where(and(eq(schema.sessions.id, sessionId,), eq(schema.sessions.userId, c.get('accountId',)!,),),) + .limit(1,) if (!session) { - return c.json({ error: "Session not found", statusCode: 404 }, 404); + return c.json({ error: 'Session not found', statusCode: 404, }, 404,) } - await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)); - return c.json({ ok: true }); + await db.delete(schema.sessions,).where(eq(schema.sessions.id, sessionId,),) + return c.json({ ok: true, },) } // Delete own account -export async function deleteMe(c: Context) { - const { password, confirmSlug } = await c.req.json(); +export async function deleteMe(c: Context,) { + const { password, confirmSlug, } = await c.req.json() - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.id, c.get('accountId')!)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.id, c.get('accountId',)!,),) + .limit(1,) if (!account?.passwordHash) { - return c.json({ error: "Cannot delete this account type", statusCode: 400 }, 400); + return c.json({ error: 'Cannot delete this account type', statusCode: 400, }, 400,) } if (confirmSlug !== account.slug) { - return c.json({ error: "Username confirmation does not match", statusCode: 422 }, 422); + return c.json({ error: 'Username confirmation does not match', statusCode: 422, }, 422,) } - const valid = await bcrypt.compare(password, account.passwordHash); + const valid = await bcrypt.compare(password, account.passwordHash,) if (!valid) { - return c.json({ error: "Invalid password", statusCode: 401 }, 401); + return c.json({ error: 'Invalid password', statusCode: 401, }, 401,) } // Check for owned collections - const [collCount] = await db - .select({ count: count() }) - .from(schema.collections) - .where(eq(schema.collections.accountId, account.id)); + const [collCount,] = await db + .select({ count: count(), },) + .from(schema.collections,) + .where(eq(schema.collections.accountId, account.id,),) if (collCount && collCount.count > 0) { return c.json({ error: `You still own ${collCount.count} collection(s). Transfer or delete them before deleting your account.`, statusCode: 422, - }, 422); + }, 422,) } // Clean up S3 avatars try { - const avatarKeys = await listS3Objects(`avatars/${account.id}/`); + const avatarKeys = await listS3Objects(`avatars/${account.id}/`,) if (avatarKeys.length > 0) { - await deleteS3Objects(avatarKeys); + await deleteS3Objects(avatarKeys,) } } catch { // Non-fatal: avatar cleanup failed } // Cascade will handle sessions, memberships, api keys - await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)); - clearSessionCookie(c); - return c.json({ ok: true }); + await db.delete(schema.accounts,).where(eq(schema.accounts.id, account.id,),) + clearSessionCookie(c,) + return c.json({ ok: true, },) } // --- Forgot Password --- -export async function forgotPassword(c: Context) { - const { email } = await c.req.json(); +export async function forgotPassword(c: Context,) { + const { email, } = await c.req.json() - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.email, email)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.email, email,),) + .limit(1,) // Always return success to prevent email enumeration if (!account) { - return c.json({ ok: true }); + return c.json({ ok: true, },) } - const rawToken = uuidv4(); - const tokenHash = await bcrypt.hash(rawToken, 10); - const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + const rawToken = uuidv4() + const tokenHash = await bcrypt.hash(rawToken, 10,) + const expiresAt = new Date(Date.now() + 60 * 60 * 1000,) // 1 hour - await db.insert(schema.passwordResetTokens).values({ + await db.insert(schema.passwordResetTokens,).values({ userId: account.id, tokenHash, expiresAt, - }); + },) // Send email (no-op if SMTP not configured) - const origin = new URL(c.req.url).origin; - const resetUrl = `${origin}/reset-password?token=${rawToken}&email=${encodeURIComponent(email)}`; + const origin = new URL(c.req.url,).origin + const resetUrl = `${origin}/reset-password?token=${rawToken}&email=${encodeURIComponent(email,)}` await sendEmail({ to: email, - subject: "Reset your Underlay password", - text: `Click here to reset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you didn't request this, ignore this email.`, - html: `

Click here to reset your password.

This link expires in 1 hour. If you didn't request this, ignore this email.

`, - }); - - return c.json({ ok: true }); + subject: 'Reset your Underlay password', + text: + `Click here to reset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you didn't request this, ignore this email.`, + html: + `

Click here to reset your password.

This link expires in 1 hour. If you didn't request this, ignore this email.

`, + },) + + return c.json({ ok: true, },) } // --- Reset Password --- -export async function resetPassword(c: Context) { - const { email, token, newPassword } = await c.req.json(); +export async function resetPassword(c: Context,) { + const { email, token, newPassword, } = await c.req.json() if (newPassword.length < 8) { - return c.json({ error: "Password must be at least 8 characters", statusCode: 422 }, 422); + return c.json({ error: 'Password must be at least 8 characters', statusCode: 422, }, 422,) } - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.email, email)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.email, email,),) + .limit(1,) if (!account) { - return c.json({ error: "Invalid or expired reset link", statusCode: 400 }, 400); + return c.json({ error: 'Invalid or expired reset link', statusCode: 400, }, 400,) } // Find valid unused tokens for this user const tokens = await db .select() - .from(schema.passwordResetTokens) + .from(schema.passwordResetTokens,) .where(and( - eq(schema.passwordResetTokens.userId, account.id), - )); + eq(schema.passwordResetTokens.userId, account.id,), + ),) - let validToken = null; + let validToken = null for (const t of tokens) { - if (t.usedAt) continue; - if (new Date(t.expiresAt) < new Date()) continue; - const match = await bcrypt.compare(token, t.tokenHash); + if (t.usedAt) continue + if (new Date(t.expiresAt,) < new Date()) continue + const match = await bcrypt.compare(token, t.tokenHash,) if (match) { - validToken = t; - break; + validToken = t + break } } if (!validToken) { - return c.json({ error: "Invalid or expired reset link", statusCode: 400 }, 400); + return c.json({ error: 'Invalid or expired reset link', statusCode: 400, }, 400,) } - const newHash = await bcrypt.hash(newPassword, 10); - await db.update(schema.accounts).set({ passwordHash: newHash }).where(eq(schema.accounts.id, account.id)); + const newHash = await bcrypt.hash(newPassword, 10,) + await db.update(schema.accounts,).set({ passwordHash: newHash, },).where(eq(schema.accounts.id, account.id,),) await db - .update(schema.passwordResetTokens) - .set({ usedAt: new Date() }) - .where(eq(schema.passwordResetTokens.id, validToken.id)); + .update(schema.passwordResetTokens,) + .set({ usedAt: new Date(), },) + .where(eq(schema.passwordResetTokens.id, validToken.id,),) - return c.json({ ok: true }); + return c.json({ ok: true, },) } // Create API key -export async function createKey(c: Context) { - const { label, scope, collectionId, expiresIn } = await c.req.json(); +export async function createKey(c: Context,) { + const { label, scope, collectionId, expiresIn, } = await c.req.json() - const rawKey = `ul_${uuidv4().replace(/-/g, "")}`; - const keyHash = await bcrypt.hash(rawKey, 10); - const keyPrefix = rawKey.slice(0, 12); + const rawKey = `ul_${uuidv4().replace(/-/g, '',)}` + const keyHash = await bcrypt.hash(rawKey, 10,) + const keyPrefix = rawKey.slice(0, 12,) const expiresAt = expiresIn - ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) - : null; + ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000,) + : null - const [key] = await db - .insert(schema.apiKeys) + const [key,] = await db + .insert(schema.apiKeys,) .values({ - accountId: c.get('accountId')!, + accountId: c.get('accountId',)!, scope, keyHash, keyPrefix, label, collectionId: collectionId ?? null, expiresAt, - }) - .returning(); + },) + .returning() return c.json({ id: key!.id, @@ -513,11 +532,11 @@ export async function createKey(c: Context) { keyPrefix, collectionId: collectionId ?? null, expiresAt, - }, 201); + }, 201,) } // List API keys -export async function listKeys(c: Context) { +export async function listKeys(c: Context,) { const keys = await db .select({ id: schema.apiKeys.id, @@ -528,65 +547,65 @@ export async function listKeys(c: Context) { expiresAt: schema.apiKeys.expiresAt, createdAt: schema.apiKeys.createdAt, lastUsedAt: schema.apiKeys.lastUsedAt, - }) - .from(schema.apiKeys) - .where(eq(schema.apiKeys.accountId, c.get('accountId')!)); - return c.json(keys); + },) + .from(schema.apiKeys,) + .where(eq(schema.apiKeys.accountId, c.get('accountId',)!,),) + return c.json(keys,) } // Delete API key -export async function deleteKey(c: Context) { - const id = c.req.param('id')!; - const [key] = await db +export async function deleteKey(c: Context,) { + const id = c.req.param('id',)! + const [key,] = await db .select() - .from(schema.apiKeys) - .where(eq(schema.apiKeys.id, id)) - .limit(1); + .from(schema.apiKeys,) + .where(eq(schema.apiKeys.id, id,),) + .limit(1,) - if (!key || key.accountId !== c.get('accountId')) { - return c.json({ error: "Key not found", statusCode: 404 }, 404); + if (!key || key.accountId !== c.get('accountId',)) { + return c.json({ error: 'Key not found', statusCode: 404, }, 404,) } - await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)); - return c.json({ ok: true }); + await db.delete(schema.apiKeys,).where(eq(schema.apiKeys.id, id,),) + return c.json({ ok: true, },) } // --- Org-scoped API Keys --- // Create API key for an org -export async function createOrgKey(c: Context) { - const slug = c.req.param('slug')!; - const { label, scope, collectionId, expiresIn } = await c.req.json(); +export async function createOrgKey(c: Context,) { + const slug = c.req.param('slug',)! + const { label, scope, collectionId, expiresIn, } = await c.req.json() - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) // Must be owner or admin - const [membership] = await db + const [membership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!membership || membership.role === "member") { - return c.json({ error: "Must be an owner or admin to manage org API keys", statusCode: 403 }, 403); + if (!membership || membership.role === 'member') { + return c.json({ error: 'Must be an owner or admin to manage org API keys', statusCode: 403, }, 403,) } - const rawKey = `ul_${uuidv4().replace(/-/g, "")}`; - const keyHash = await bcrypt.hash(rawKey, 10); - const keyPrefix = rawKey.slice(0, 12); + const rawKey = `ul_${uuidv4().replace(/-/g, '',)}` + const keyHash = await bcrypt.hash(rawKey, 10,) + const keyPrefix = rawKey.slice(0, 12,) const expiresAt = expiresIn - ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) - : null; + ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000,) + : null - const [key] = await db - .insert(schema.apiKeys) + const [key,] = await db + .insert(schema.apiKeys,) .values({ accountId: org.id, scope, @@ -595,8 +614,8 @@ export async function createOrgKey(c: Context) { label, collectionId: collectionId ?? null, expiresAt, - }) - .returning(); + },) + .returning() return c.json({ id: key!.id, @@ -606,29 +625,29 @@ export async function createOrgKey(c: Context) { keyPrefix, collectionId: collectionId ?? null, expiresAt, - }, 201); + }, 201,) } // List org API keys -export async function listOrgKeys(c: Context) { - const slug = c.req.param('slug')!; +export async function listOrgKeys(c: Context,) { + const slug = c.req.param('slug',)! - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) // Must be a member - const [membership] = await db + const [membership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!membership) return c.json({ error: "Forbidden", statusCode: 403 }, 403); + if (!membership) return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) const keys = await db .select({ @@ -640,110 +659,113 @@ export async function listOrgKeys(c: Context) { expiresAt: schema.apiKeys.expiresAt, createdAt: schema.apiKeys.createdAt, lastUsedAt: schema.apiKeys.lastUsedAt, - }) - .from(schema.apiKeys) - .where(eq(schema.apiKeys.accountId, org.id)); + },) + .from(schema.apiKeys,) + .where(eq(schema.apiKeys.accountId, org.id,),) - return c.json(keys); + return c.json(keys,) } // Delete org API key -export async function deleteOrgKey(c: Context) { - const slug = c.req.param('slug')!; - const id = c.req.param('id')!; +export async function deleteOrgKey(c: Context,) { + const slug = c.req.param('slug',)! + const id = c.req.param('id',)! - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) - const [membership] = await db + const [membership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!membership || membership.role === "member") { - return c.json({ error: "Must be an owner or admin to manage org API keys", statusCode: 403 }, 403); + if (!membership || membership.role === 'member') { + return c.json({ error: 'Must be an owner or admin to manage org API keys', statusCode: 403, }, 403,) } - const [key] = await db + const [key,] = await db .select() - .from(schema.apiKeys) - .where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.accountId, org.id))) - .limit(1); + .from(schema.apiKeys,) + .where(and(eq(schema.apiKeys.id, id,), eq(schema.apiKeys.accountId, org.id,),),) + .limit(1,) - if (!key) return c.json({ error: "Key not found", statusCode: 404 }, 404); + if (!key) return c.json({ error: 'Key not found', statusCode: 404, }, 404,) - await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)); - return c.json({ ok: true }); + await db.delete(schema.apiKeys,).where(eq(schema.apiKeys.id, id,),) + return c.json({ ok: true, },) } // --- Org Management --- // Create organization -export async function createOrg(c: Context) { - const { slug, displayName } = await c.req.json(); +export async function createOrg(c: Context,) { + const { slug, displayName, } = await c.req.json() - if (RESERVED_SLUGS.has(slug.toLowerCase())) { - return c.json({ error: "That name is reserved", statusCode: 422 }, 422); + if (RESERVED_SLUGS.has(slug.toLowerCase(),)) { + return c.json({ error: 'That name is reserved', statusCode: 422, }, 422,) } - if (!/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slug) || slug.length < 2) { - return c.json({ error: "Slug must be lowercase alphanumeric with hyphens, at least 2 characters", statusCode: 422 }, 422); + if (!/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slug,) || slug.length < 2) { + return c.json({ + error: 'Slug must be lowercase alphanumeric with hyphens, at least 2 characters', + statusCode: 422, + }, 422,) } const existing = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, slug)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.slug, slug,),) + .limit(1,) if (existing.length > 0) { - return c.json({ error: "Name already taken", statusCode: 409 }, 409); + return c.json({ error: 'Name already taken', statusCode: 409, }, 409,) } - const id = uuidv4(); - await db.insert(schema.accounts).values({ + const id = uuidv4() + await db.insert(schema.accounts,).values({ id, slug, - type: "org", + type: 'org', displayName, - }); + },) // Add the creating user as owner - await db.insert(schema.orgMemberships).values({ + await db.insert(schema.orgMemberships,).values({ orgId: id, - userId: c.get('accountId')!, - role: "owner", - }); + userId: c.get('accountId',)!, + role: 'owner', + },) - return c.json({ id, slug, displayName, type: "org" }, 201); + return c.json({ id, slug, displayName, type: 'org', }, 201,) } // List org members -export async function listMembers(c: Context) { - const slug = c.req.param('slug')!; +export async function listMembers(c: Context,) { + const slug = c.req.param('slug',)! - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) // Must be a member to view - const [membership] = await db + const [membership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!membership) return c.json({ error: "Forbidden", statusCode: 403 }, 403); + if (!membership) return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) const members = await db .select({ @@ -751,303 +773,307 @@ export async function listMembers(c: Context) { role: schema.orgMemberships.role, slug: schema.accounts.slug, displayName: schema.accounts.displayName, - }) - .from(schema.orgMemberships) - .innerJoin(schema.accounts, eq(schema.orgMemberships.userId, schema.accounts.id)) - .where(eq(schema.orgMemberships.orgId, org.id)); + },) + .from(schema.orgMemberships,) + .innerJoin(schema.accounts, eq(schema.orgMemberships.userId, schema.accounts.id,),) + .where(eq(schema.orgMemberships.orgId, org.id,),) - return c.json(members); + return c.json(members,) } // Add org member -export async function addMember(c: Context) { - const slug = c.req.param('slug')!; - const { username, role } = await c.req.json(); +export async function addMember(c: Context,) { + const slug = c.req.param('slug',)! + const { username, role, } = await c.req.json() - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) // Must be owner or admin - const [callerMembership] = await db + const [callerMembership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!callerMembership || callerMembership.role === "member") { - return c.json({ error: "Must be an owner or admin to add members", statusCode: 403 }, 403); + if (!callerMembership || callerMembership.role === 'member') { + return c.json({ error: 'Must be an owner or admin to add members', statusCode: 403, }, 403,) } // Find user to add - const [user] = await db + const [user,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, username), eq(schema.accounts.type, "user"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, username,), eq(schema.accounts.type, 'user',),),) + .limit(1,) - if (!user) return c.json({ error: "User not found", statusCode: 404 }, 404); + if (!user) return c.json({ error: 'User not found', statusCode: 404, }, 404,) // Check not already a member - const [existing] = await db + const [existing,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, user.id))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, user.id,),),) + .limit(1,) - if (existing) return c.json({ error: "Already a member", statusCode: 409 }, 409); + if (existing) return c.json({ error: 'Already a member', statusCode: 409, }, 409,) - await db.insert(schema.orgMemberships).values({ + await db.insert(schema.orgMemberships,).values({ orgId: org.id, userId: user.id, - role: role ?? "member", - }); + role: role ?? 'member', + },) - return c.json({ ok: true, username, role }, 201); + return c.json({ ok: true, username, role, }, 201,) } // Update member role -export async function updateMember(c: Context) { - const slug = c.req.param('slug')!; - const userId = c.req.param('userId')!; - const { role } = await c.req.json(); +export async function updateMember(c: Context,) { + const slug = c.req.param('slug',)! + const userId = c.req.param('userId',)! + const { role, } = await c.req.json() - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) // Must be owner - const [callerMembership] = await db + const [callerMembership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!callerMembership || callerMembership.role !== "owner") { - return c.json({ error: "Must be an owner to change roles", statusCode: 403 }, 403); + if (!callerMembership || callerMembership.role !== 'owner') { + return c.json({ error: 'Must be an owner to change roles', statusCode: 403, }, 403,) } await db - .update(schema.orgMemberships) - .set({ role }) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, userId))); + .update(schema.orgMemberships,) + .set({ role, },) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, userId,),),) - return c.json({ ok: true }); + return c.json({ ok: true, },) } // Remove member -export async function removeMember(c: Context) { - const slug = c.req.param('slug')!; - const userId = c.req.param('userId')!; +export async function removeMember(c: Context,) { + const slug = c.req.param('slug',)! + const userId = c.req.param('userId',)! - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) // Must be owner or admin (or removing yourself) - const [callerMembership] = await db + const [callerMembership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - const isSelf = c.get('accountId') === userId; - if (!callerMembership || (callerMembership.role === "member" && !isSelf)) { - return c.json({ error: "Forbidden", statusCode: 403 }, 403); + const isSelf = c.get('accountId',) === userId + if (!callerMembership || (callerMembership.role === 'member' && !isSelf)) { + return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) } await db - .delete(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, userId))); + .delete(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, userId,),),) - return c.json({ ok: true }); + return c.json({ ok: true, },) } // Update org profile -export async function updateOrg(c: Context) { - const slug = c.req.param('slug')!; - const { displayName, bio, website, location } = await c.req.json(); +export async function updateOrg(c: Context,) { + const slug = c.req.param('slug',)! + const { displayName, bio, website, location, } = await c.req.json() - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) // Must be owner - const [callerMembership] = await db + const [callerMembership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!callerMembership || callerMembership.role !== "owner") { - return c.json({ error: "Must be an owner to update the organization", statusCode: 403 }, 403); + if (!callerMembership || callerMembership.role !== 'owner') { + return c.json({ error: 'Must be an owner to update the organization', statusCode: 403, }, 403,) } - const updates: Record = {}; - if (displayName !== undefined) updates.displayName = displayName; - if (bio !== undefined) updates.bio = bio; - if (website !== undefined) updates.website = website; - if (location !== undefined) updates.location = location; + const updates: Record = {} + if (displayName !== undefined) updates.displayName = displayName + if (bio !== undefined) updates.bio = bio + if (website !== undefined) updates.website = website + if (location !== undefined) updates.location = location - if (Object.keys(updates).length > 0) { - await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, org.id)); + if (Object.keys(updates,).length > 0) { + await db.update(schema.accounts,).set(updates,).where(eq(schema.accounts.id, org.id,),) } - return c.json({ ok: true }); + return c.json({ ok: true, },) } // Upload org avatar -export async function uploadOrgAvatar(c: Context) { - const slug = c.req.param('slug')!; +export async function uploadOrgAvatar(c: Context,) { + const slug = c.req.param('slug',)! - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) - const [membership] = await db + const [membership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!membership || membership.role !== "owner") { - return c.json({ error: "Must be an owner to update the organization avatar", statusCode: 403 }, 403); + if (!membership || membership.role !== 'owner') { + return c.json({ error: 'Must be an owner to update the organization avatar', statusCode: 403, }, 403,) } - const body = await c.req.parseBody(); - const file = Object.values(body).find((v): v is File => v instanceof File); + const body = await c.req.parseBody() + const file = Object.values(body,).find((v,): v is File => v instanceof File) if (!file) { - return c.json({ error: "No file uploaded", statusCode: 400 }, 400); + return c.json({ error: 'No file uploaded', statusCode: 400, }, 400,) } - const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; - if (!allowedTypes.includes(file.type)) { - return c.json({ error: "Only JPEG, PNG, GIF, and WebP images are allowed", statusCode: 422 }, 422); + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp',] + if (!allowedTypes.includes(file.type,)) { + return c.json({ error: 'Only JPEG, PNG, GIF, and WebP images are allowed', statusCode: 422, }, 422,) } - const buffer = Buffer.from(await file.arrayBuffer()); + const buffer = Buffer.from(await file.arrayBuffer(),) if (buffer.length > 5 * 1024 * 1024) { - return c.json({ error: "Image must be less than 5MB", statusCode: 422 }, 422); + return c.json({ error: 'Image must be less than 5MB', statusCode: 422, }, 422,) } - const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]; - const key = `avatars/${org.id}/${Date.now()}.${ext}`; + const ext = file.type.split('/',)[1] === 'jpeg' ? 'jpg' : file.type.split('/',)[1] + const key = `avatars/${org.id}/${Date.now()}.${ext}` - await uploadToS3(key, buffer, file.type); + await uploadToS3(key, buffer, file.type,) - await db.update(schema.accounts).set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` }).where(eq(schema.accounts.id, org.id)); + await db.update(schema.accounts,).set({ avatarUrl: `${ASSETS_BASE_URL}/${key}`, },).where( + eq(schema.accounts.id, org.id,), + ) - return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }); + return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}`, },) } // --- Org Invitations --- // Invite user to org -export async function createInvitation(c: Context) { - const slug = c.req.param('slug')!; - const { email, role } = await c.req.json(); +export async function createInvitation(c: Context,) { + const slug = c.req.param('slug',)! + const { email, role, } = await c.req.json() - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) - const [callerMembership] = await db + const [callerMembership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!callerMembership || callerMembership.role === "member") { - return c.json({ error: "Must be an owner or admin to invite members", statusCode: 403 }, 403); + if (!callerMembership || callerMembership.role === 'member') { + return c.json({ error: 'Must be an owner or admin to invite members', statusCode: 403, }, 403,) } // Check if already a member (by email) - const [existingUser] = await db + const [existingUser,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.email, email)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.email, email,),) + .limit(1,) if (existingUser) { - const [existingMembership] = await db + const [existingMembership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, existingUser.id))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, existingUser.id,),),) + .limit(1,) if (existingMembership) { - return c.json({ error: "User is already a member", statusCode: 409 }, 409); + return c.json({ error: 'User is already a member', statusCode: 409, }, 409,) } } - const token = uuidv4(); - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + const token = uuidv4() + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000,) // 7 days - await db.insert(schema.orgInvitations).values({ + await db.insert(schema.orgInvitations,).values({ orgId: org.id, email, role, - invitedBy: c.get('accountId')!, + invitedBy: c.get('accountId',)!, token, expiresAt, - }); + },) // Send invitation email - const origin = new URL(c.req.url).origin; - const inviteUrl = `${origin}/invitations/accept?token=${token}`; + const origin = new URL(c.req.url,).origin + const inviteUrl = `${origin}/invitations/accept?token=${token}` await sendEmail({ to: email, subject: `You've been invited to join ${org.displayName} on Underlay`, - text: `You've been invited to join ${org.displayName} as a ${role}.\n\nAccept: ${inviteUrl}\n\nThis invitation expires in 7 days.`, - html: `

You've been invited to join ${org.displayName} as a ${role}.

Accept invitation

This invitation expires in 7 days.

`, - }); + text: + `You've been invited to join ${org.displayName} as a ${role}.\n\nAccept: ${inviteUrl}\n\nThis invitation expires in 7 days.`, + html: + `

You've been invited to join ${org.displayName} as a ${role}.

Accept invitation

This invitation expires in 7 days.

`, + },) - return c.json({ ok: true }, 201); + return c.json({ ok: true, }, 201,) } // List pending invitations for an org -export async function listInvitations(c: Context) { - const slug = c.req.param('slug')!; +export async function listInvitations(c: Context,) { + const slug = c.req.param('slug',)! - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) - const [membership] = await db + const [membership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!membership) return c.json({ error: "Forbidden", statusCode: 403 }, 403); + if (!membership) return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) const invitations = await db .select({ @@ -1057,121 +1083,120 @@ export async function listInvitations(c: Context) { expiresAt: schema.orgInvitations.expiresAt, acceptedAt: schema.orgInvitations.acceptedAt, createdAt: schema.orgInvitations.createdAt, - }) - .from(schema.orgInvitations) - .where(eq(schema.orgInvitations.orgId, org.id)); + },) + .from(schema.orgInvitations,) + .where(eq(schema.orgInvitations.orgId, org.id,),) - return c.json(invitations); + return c.json(invitations,) } // Cancel an invitation -export async function deleteInvitation(c: Context) { - const slug = c.req.param('slug')!; - const id = c.req.param('id')!; +export async function deleteInvitation(c: Context,) { + const slug = c.req.param('slug',)! + const id = c.req.param('id',)! - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) - const [membership] = await db + const [membership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!membership || membership.role === "member") { - return c.json({ error: "Must be an owner or admin to cancel invitations", statusCode: 403 }, 403); + if (!membership || membership.role === 'member') { + return c.json({ error: 'Must be an owner or admin to cancel invitations', statusCode: 403, }, 403,) } - await db.delete(schema.orgInvitations).where(eq(schema.orgInvitations.id, id)); - return c.json({ ok: true }); + await db.delete(schema.orgInvitations,).where(eq(schema.orgInvitations.id, id,),) + return c.json({ ok: true, },) } // Accept an invitation (public, token-based) -export async function acceptInvitation(c: Context) { - const { token } = await c.req.json(); +export async function acceptInvitation(c: Context,) { + const { token, } = await c.req.json() - const [invitation] = await db + const [invitation,] = await db .select() - .from(schema.orgInvitations) - .where(eq(schema.orgInvitations.token, token)) - .limit(1); + .from(schema.orgInvitations,) + .where(eq(schema.orgInvitations.token, token,),) + .limit(1,) if (!invitation) { - return c.json({ error: "Invitation not found", statusCode: 404 }, 404); + return c.json({ error: 'Invitation not found', statusCode: 404, }, 404,) } if (invitation.acceptedAt) { - return c.json({ error: "Invitation already accepted", statusCode: 409 }, 409); + return c.json({ error: 'Invitation already accepted', statusCode: 409, }, 409,) } - if (new Date(invitation.expiresAt) < new Date()) { - return c.json({ error: "Invitation has expired", statusCode: 410 }, 410); + if (new Date(invitation.expiresAt,) < new Date()) { + return c.json({ error: 'Invitation has expired', statusCode: 410, }, 410,) } // Verify the logged-in user's email matches the invitation - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.id, c.get('accountId')!)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.id, c.get('accountId',)!,),) + .limit(1,) if (!account || account.email !== invitation.email) { - return c.json({ error: "This invitation was sent to a different email address", statusCode: 403 }, 403); + return c.json({ error: 'This invitation was sent to a different email address', statusCode: 403, }, 403,) } // Add to org - await db.insert(schema.orgMemberships).values({ + await db.insert(schema.orgMemberships,).values({ orgId: invitation.orgId, - userId: c.get('accountId')!, - role: invitation.role as "owner" | "admin" | "member", - }); + userId: c.get('accountId',)!, + role: invitation.role as 'owner' | 'admin' | 'member', + },) // Mark invitation as accepted await db - .update(schema.orgInvitations) - .set({ acceptedAt: new Date() }) - .where(eq(schema.orgInvitations.id, invitation.id)); + .update(schema.orgInvitations,) + .set({ acceptedAt: new Date(), },) + .where(eq(schema.orgInvitations.id, invitation.id,),) // Get org slug for redirect - const [org] = await db - .select({ slug: schema.accounts.slug }) - .from(schema.accounts) - .where(eq(schema.accounts.id, invitation.orgId)) - .limit(1); + const [org,] = await db + .select({ slug: schema.accounts.slug, },) + .from(schema.accounts,) + .where(eq(schema.accounts.id, invitation.orgId,),) + .limit(1,) - return c.json({ ok: true, orgSlug: org?.slug ?? "" }); + return c.json({ ok: true, orgSlug: org?.slug ?? '', },) } // Delete org -export async function deleteOrg(c: Context) { - const slug = c.req.param('slug')!; +export async function deleteOrg(c: Context,) { + const slug = c.req.param('slug',)! - const [org] = await db + const [org,] = await db .select() - .from(schema.accounts) - .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, "org"))) - .limit(1); + .from(schema.accounts,) + .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) + .limit(1,) - if (!org) return c.json({ error: "Organization not found", statusCode: 404 }, 404); + if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) // Must be owner - const [callerMembership] = await db + const [callerMembership,] = await db .select() - .from(schema.orgMemberships) - .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, c.get('accountId')!))) - .limit(1); + .from(schema.orgMemberships,) + .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) + .limit(1,) - if (!callerMembership || callerMembership.role !== "owner") { - return c.json({ error: "Must be an owner to delete the organization", statusCode: 403 }, 403); + if (!callerMembership || callerMembership.role !== 'owner') { + return c.json({ error: 'Must be an owner to delete the organization', statusCode: 403, }, 403,) } // Cascade will handle memberships, collections, etc. - await db.delete(schema.accounts).where(eq(schema.accounts.id, org.id)); - return c.json({ ok: true }); + await db.delete(schema.accounts,).where(eq(schema.accounts.id, org.id,),) + return c.json({ ok: true, },) } - diff --git a/src/api/admin.ts b/src/api/admin.ts index b4c672f..beefc32 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -1,66 +1,66 @@ -import type { Context } from 'hono' -import { streamSSE } from "hono/streaming"; -import { type AuthEnv } from "./auth.server.js"; -import { getMirrorConfig } from "../lib/mirror-config.js"; +import type { Context, } from 'hono' +import { streamSSE, } from 'hono/streaming' +import { getMirrorConfig, } from '../lib/mirror-config.js' import { - runMirrorSync, - testUpstreamConnection, - getMirrorStatus, - getSyncHistory, - syncEvents, - stopSync, cleanupStaleRuns, - isSyncRunning, getActiveRunId, getActiveRunLogs, + getMirrorStatus, + getSyncHistory, + isSyncRunning, + runMirrorSync, + stopSync, + syncEvents, type SyncProgressEvent, -} from "../lib/mirror-sync.js"; + testUpstreamConnection, +} from '../lib/mirror-sync.js' +import { type AuthEnv, } from './auth.server.js' // Get mirror status -export async function mirrorStatus(c: Context) { - const status = await getMirrorStatus(); - return c.json(status); +export async function mirrorStatus(c: Context,) { + const status = await getMirrorStatus() + return c.json(status,) } // Test upstream connection -export async function mirrorTest(c: Context) { - const config = getMirrorConfig(); - const result = await testUpstreamConnection(config.upstream); - return c.json(result); +export async function mirrorTest(c: Context,) { + const config = getMirrorConfig() + const result = await testUpstreamConnection(config.upstream,) + return c.json(result,) } // Trigger a sync manually (fire-and-forget, client uses SSE for progress) -export async function mirrorSync(c: Context) { +export async function mirrorSync(c: Context,) { if (isSyncRunning()) { - return c.json({ started: false, error: "A sync is already running" }); + return c.json({ started: false, error: 'A sync is already running', },) } // Start sync in background — don't await - runMirrorSync("manual").catch((err) => { - console.error("[mirror-sync] Unhandled sync error:", err); - }); - return c.json({ started: true }); + runMirrorSync('manual',).catch((err,) => { + console.error('[mirror-sync] Unhandled sync error:', err,) + },) + return c.json({ started: true, },) } // Stop a running sync (also cleans up stale DB rows from crashed processes) -export async function mirrorSyncStop(c: Context) { - const stopped = stopSync(); +export async function mirrorSyncStop(c: Context,) { + const stopped = stopSync() if (!stopped) { // No active sync in this process — clean up stale DB rows - const cleaned = await cleanupStaleRuns(); - return c.json({ stopped: false, cleaned }); + const cleaned = await cleanupStaleRuns() + return c.json({ stopped: false, cleaned, },) } - return c.json({ stopped: true }); + return c.json({ stopped: true, },) } // SSE endpoint for live sync progress (replays buffered logs on connect) -export async function mirrorSyncProgress(c: Context) { - return streamSSE(c, async (stream) => { +export async function mirrorSyncProgress(c: Context,) { + return streamSSE(c, async (stream,) => { // Replay buffered logs so reconnects/refreshes don't lose history - const buffered = getActiveRunLogs(); + const buffered = getActiveRunLogs() if (buffered.length > 0) { for (const msg of buffered) { const replayEvent: SyncProgressEvent = { - type: "collection", + type: 'collection', message: msg, progress: { collectionsTotal: 0, @@ -70,51 +70,50 @@ export async function mirrorSyncProgress(c: Context) { filesSkipped: 0, errors: 0, }, - }; - await stream.writeSSE({ data: JSON.stringify(replayEvent) }); + } + await stream.writeSSE({ data: JSON.stringify(replayEvent,), },) } } // If no sync is running, close immediately if (!isSyncRunning()) { - return; + return } - const onProgress = async (event: SyncProgressEvent) => { - await stream.writeSSE({ data: JSON.stringify(event) }); - if (event.type === "done") { - setTimeout(() => stream.close(), 100); + const onProgress = async (event: SyncProgressEvent,) => { + await stream.writeSSE({ data: JSON.stringify(event,), },) + if (event.type === 'done') { + setTimeout(() => stream.close(), 100,) } - }; + } - syncEvents.on("progress", onProgress); + syncEvents.on('progress', onProgress,) stream.onAbort(() => { - syncEvents.off("progress", onProgress); - }); + syncEvents.off('progress', onProgress,) + },) // Keep the stream open until aborted or done - await new Promise((resolve) => { - stream.onAbort(() => resolve()); - }); - }); + await new Promise((resolve,) => { + stream.onAbort(() => resolve()) + },) + },) } // Get current sync running state (for page refresh reconnection) -export async function mirrorSyncActive(c: Context) { +export async function mirrorSyncActive(c: Context,) { return c.json({ running: isSyncRunning(), runId: getActiveRunId(), logs: getActiveRunLogs(), - }); + },) } // Sync history -export async function mirrorHistory(c: Context) { +export async function mirrorHistory(c: Context,) { const limit = Math.min( - Number(c.req.query("limit")) || 20, + Number(c.req.query('limit',),) || 20, 100, - ); - return c.json(await getSyncHistory(limit)); + ) + return c.json(await getSyncHistory(limit,),) } - diff --git a/src/api/ark-middleware.server.ts b/src/api/ark-middleware.server.ts index 60819e8..cd9da47 100644 --- a/src/api/ark-middleware.server.ts +++ b/src/api/ark-middleware.server.ts @@ -1,27 +1,27 @@ -import type { MiddlewareHandler } from 'hono' -import { DEFAULT_NAAN, buildErc } from '../lib/ark.js' +import type { MiddlewareHandler, } from 'hono' +import { buildErc, DEFAULT_NAAN, } from '../lib/ark.js' /** * Hono middleware that intercepts /ark:NAAN/... URLs and resolves them. * In the new single-server architecture, we call the API route handler internally * via a local fetch to localhost (same process). */ -export const arkMiddleware: MiddlewareHandler = async (c, _next) => { - const url = new URL(c.req.url) +export const arkMiddleware: MiddlewareHandler = async (c, _next,) => { + const url = new URL(c.req.url,) const pathname = url.pathname const search = url.search - if (!pathname.startsWith('/ark:')) { + if (!pathname.startsWith('/ark:',)) { return _next() } - const fullPath = pathname.slice(1) // strip leading / + const fullPath = pathname.slice(1,) // strip leading / // Check if this is a root NAAN path - const afterLabel = fullPath.slice(4) // strip "ark:" - const slashIdx = afterLabel.indexOf('/') - const naan = slashIdx === -1 ? afterLabel : afterLabel.slice(0, slashIdx) - const afterNaan = slashIdx === -1 ? '' : afterLabel.slice(slashIdx + 1) + const afterLabel = fullPath.slice(4,) // strip "ark:" + 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 new Response( @@ -37,37 +37,37 @@ export const arkMiddleware: MiddlewareHandler = async (c, _next) => { '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'), - { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }, + ].join('\n',), + { headers: { 'Content-Type': 'text/plain; charset=utf-8', }, }, ) } // Resolve the ARK via internal API - const port = Number(process.env.PORT) || 3000 + const port = Number(process.env.PORT,) || 3000 const apiBase = `http://localhost:${port}` - const params = new URLSearchParams({ path: fullPath }) + const params = new URLSearchParams({ path: fullPath, },) let resolveRes: Response try { - resolveRes = await fetch(`${apiBase}/api/ark/resolve?${params}`) + resolveRes = await fetch(`${apiBase}/api/ark/resolve?${params}`,) } catch { - return new Response('ARK resolver unavailable', { status: 503 }) + return new Response('ARK resolver unavailable', { status: 503, },) } if (!resolveRes.ok) { const body = await resolveRes.json().catch(() => ({})) if (body?.type === 'not_found') { - return new Response('ARK not found', { status: 404 }) + return new Response('ARK not found', { status: 404, },) } - return new Response('ARK resolution error', { status: 502 }) + return new Response('ARK resolution error', { status: 502, },) } const data = await resolveRes.json() if (data.type === 'not_found') { - return new Response('ARK not found', { status: 404 }) + return new Response('ARK not found', { status: 404, },) } - const { metadata } = data + const { metadata, } = data const resolvedNaan = metadata?.naan ?? DEFAULT_NAAN // Handle inflections @@ -79,23 +79,23 @@ export const arkMiddleware: MiddlewareHandler = async (c, _next) => { when: metadata.when ?? '(:unkn)', where: metadata.where ?? metadata.arkUrl ?? '(:unkn)', naan: resolvedNaan, - }) + },) return new Response(erc, { - headers: { 'Content-Type': 'text/plain; charset=utf-8' }, - }) + headers: { 'Content-Type': 'text/plain; charset=utf-8', }, + },) } if (search === '?json') { - return new Response(JSON.stringify(metadata, null, 2), { - headers: { 'Content-Type': 'application/json' }, - }) + return new Response(JSON.stringify(metadata, null, 2,), { + headers: { 'Content-Type': 'application/json', }, + },) } // Regular resolution — redirect const targetUrl = data.url - const redirectTarget = targetUrl.startsWith('/') + const redirectTarget = targetUrl.startsWith('/',) ? `${url.origin}${targetUrl}` : targetUrl - return c.redirect(redirectTarget, 302) + return c.redirect(redirectTarget, 302,) } diff --git a/src/api/ark.ts b/src/api/ark.ts index 73a87b6..aefa1e5 100644 --- a/src/api/ark.ts +++ b/src/api/ark.ts @@ -1,52 +1,52 @@ -import type { Context } from 'hono' -import { eq, and, desc } from 'drizzle-orm'; -import { db, schema } from '../db/client.server.js'; -import { type AuthEnv } from './auth.server.js'; +import { and, desc, eq, } from 'drizzle-orm' +import type { Context, } from 'hono' +import { db, schema, } from '../db/client.server.js' import { - DEFAULT_NAAN, - parseArkPath, buildArkUrl, buildErc, - formatErcDate, collectionToArkId, + DEFAULT_NAAN, + formatErcDate, getOrMintShoulder, -} from '../lib/ark.js'; + parseArkPath, +} from '../lib/ark.js' +import { type AuthEnv, } from './auth.server.js' // --- Resolution --- -export async function resolve(c: Context) { - const path = c.req.query('path'); - if (!path) return c.json({ error: 'Missing path' }, 400); +export async function resolve(c: Context,) { + const path = c.req.query('path',) + if (!path) return c.json({ error: 'Missing path', }, 400,) // path = "ark:NAAN/shoulder+collection..." - const arkLabelIdx = path.indexOf('ark:'); - if (arkLabelIdx === -1) return c.json({ error: 'Invalid ARK path' }, 400); + const arkLabelIdx = path.indexOf('ark:',) + if (arkLabelIdx === -1) return c.json({ error: 'Invalid ARK path', }, 400,) - const afterLabel = path.slice(arkLabelIdx + 4); // strip "ark:" - const slashIdx = afterLabel.indexOf('/'); - if (slashIdx === -1) return c.json({ type: 'not_found' }, 404); + const afterLabel = path.slice(arkLabelIdx + 4,) // strip "ark:" + const slashIdx = afterLabel.indexOf('/',) + if (slashIdx === -1) return c.json({ type: 'not_found', }, 404,) - const naan = afterLabel.slice(0, slashIdx); - const pathAfterNaan = afterLabel.slice(slashIdx + 1); + const naan = afterLabel.slice(0, slashIdx,) + const pathAfterNaan = afterLabel.slice(slashIdx + 1,) // Root NAAN path (no name part) — handled in middleware; shouldn't reach here - if (!pathAfterNaan) return c.json({ type: 'not_found' }, 404); + if (!pathAfterNaan) return c.json({ type: 'not_found', }, 404,) - const components = parseArkPath(pathAfterNaan); - if (!components) return c.json({ type: 'not_found' }, 404); + const components = parseArkPath(pathAfterNaan,) + if (!components) return c.json({ type: 'not_found', }, 404,) - const { shoulder, collectionArkId, version, recordType, recordId } = components; + const { shoulder, collectionArkId, version, recordType, recordId, } = components // Lookup shoulder → account - const [shoulderRow] = await db - .select({ accountId: schema.arkShoulders.accountId }) - .from(schema.arkShoulders) - .where(eq(schema.arkShoulders.shoulder, shoulder)) - .limit(1); - if (!shoulderRow) return c.json({ type: 'not_found' }, 404); + const [shoulderRow,] = await db + .select({ accountId: schema.arkShoulders.accountId, },) + .from(schema.arkShoulders,) + .where(eq(schema.arkShoulders.shoulder, shoulder,),) + .limit(1,) + if (!shoulderRow) return c.json({ type: 'not_found', }, 404,) // Lookup collectionArkId → collection + owner - const [collRow] = await db + const [collRow,] = await db .select({ collectionId: schema.arkCollections.collectionId, enabled: schema.arkCollections.enabled, @@ -57,38 +57,38 @@ export async function resolve(c: Context) { ownerName: schema.accounts.displayName, ownerNaan: schema.accounts.arkNaan, collectionAccountId: schema.collections.accountId, - }) - .from(schema.arkCollections) - .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(eq(schema.arkCollections.arkId, collectionArkId)) - .limit(1); + },) + .from(schema.arkCollections,) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id,),) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(eq(schema.arkCollections.arkId, collectionArkId,),) + .limit(1,) - if (!collRow || !collRow.enabled) return c.json({ type: 'not_found' }, 404); + if (!collRow || !collRow.enabled) return c.json({ type: 'not_found', }, 404,) // Verify the shoulder belongs to the collection's owner if (shoulderRow.accountId !== collRow.collectionAccountId) { - return c.json({ type: 'not_found' }, 404); + return c.json({ type: 'not_found', }, 404,) } - const resolvedNaan = collRow.ownerNaan ?? naan; - const { collectionId, collectionSlug, collectionName, ownerSlug, ownerName } = collRow; + const resolvedNaan = collRow.ownerNaan ?? naan + const { collectionId, collectionSlug, collectionName, ownerSlug, ownerName, } = collRow // --- Resolve version --- let versionRow: { - id: number; - number: number; - semver: string; - message: string | null; - readme: string | null; - pushedBy: string | null; - appId: string | null; - actorId: string | null; - createdAt: Date; - } | null = null; + id: number + number: number + semver: string + message: string | null + readme: string | null + pushedBy: string | null + appId: string | null + actorId: string | null + createdAt: Date + } | null = null if (version !== undefined) { - const [row] = await db + const [row,] = await db .select({ id: schema.versions.id, number: schema.versions.number, @@ -99,14 +99,14 @@ export async function resolve(c: Context) { appId: schema.versions.appId, actorId: schema.versions.actorId, createdAt: schema.versions.createdAt, - }) - .from(schema.versions) - .where(and(eq(schema.versions.collectionId, collectionId), eq(schema.versions.number, version))) - .limit(1); - if (!row) return c.json({ type: 'not_found' }, 404); - versionRow = row; + },) + .from(schema.versions,) + .where(and(eq(schema.versions.collectionId, collectionId,), eq(schema.versions.number, version,),),) + .limit(1,) + if (!row) return c.json({ type: 'not_found', }, 404,) + versionRow = row } else { - const [row] = await db + const [row,] = await db .select({ id: schema.versions.id, number: schema.versions.number, @@ -117,65 +117,65 @@ export async function resolve(c: Context) { appId: schema.versions.appId, actorId: schema.versions.actorId, createdAt: schema.versions.createdAt, - }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, collectionId)) - .orderBy(desc(schema.versions.number)) - .limit(1); - versionRow = row ?? null; + },) + .from(schema.versions,) + .where(eq(schema.versions.collectionId, collectionId,),) + .orderBy(desc(schema.versions.number,),) + .limit(1,) + versionRow = row ?? null } - const arkUrl = buildArkUrl(resolvedNaan, shoulder, collectionArkId, version, recordType, recordId); + const arkUrl = buildArkUrl(resolvedNaan, shoulder, collectionArkId, version, recordType, recordId,) // --- Record resolution --- if (recordType && recordId) { - const [artRow] = await db - .select({ redirectUrlField: schema.arkRecordTypes.redirectUrlField }) - .from(schema.arkRecordTypes) + const [artRow,] = await db + .select({ redirectUrlField: schema.arkRecordTypes.redirectUrlField, },) + .from(schema.arkRecordTypes,) .where( and( - eq(schema.arkRecordTypes.collectionId, collectionId), - eq(schema.arkRecordTypes.recordType, recordType), + eq(schema.arkRecordTypes.collectionId, collectionId,), + eq(schema.arkRecordTypes.recordType, recordType,), ), ) - .limit(1); + .limit(1,) - if (!artRow) return c.json({ type: 'not_found' }, 404); + if (!artRow) return c.json({ type: 'not_found', }, 404,) - if (!versionRow) return c.json({ type: 'not_found' }, 404); + if (!versionRow) return c.json({ type: 'not_found', }, 404,) - const [recordRow] = await db - .select({ data: schema.records.data }) - .from(schema.records) + const [recordRow,] = await db + .select({ data: schema.records.data, },) + .from(schema.records,) .where( and( - eq(schema.records.versionId, versionRow.id), - eq(schema.records.recordId, recordId), - eq(schema.records.type, recordType), + eq(schema.records.versionId, versionRow.id,), + eq(schema.records.recordId, recordId,), + eq(schema.records.type, recordType,), ), ) - .limit(1); + .limit(1,) - if (!recordRow) return c.json({ type: 'not_found' }, 404); + if (!recordRow) return c.json({ type: 'not_found', }, 404,) - const data = recordRow.data as Record; - const redirectUrl = data[artRow.redirectUrlField]; + const data = recordRow.data as Record + const redirectUrl = data[artRow.redirectUrlField] if (typeof redirectUrl !== 'string') { - return c.json({ type: 'not_found', error: 'No URL found for this record' }, 404); + return c.json({ type: 'not_found', error: 'No URL found for this record', }, 404,) } // Fetch the type schema for metadata - const [vs] = await db - .select({ schema: schema.schemas.schema }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + const [vs,] = await db + .select({ schema: schema.schemas.schema, },) + .from(schema.versionSchemas,) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id,),) .where( and( - eq(schema.versionSchemas.versionId, versionRow.id), - eq(schema.versionSchemas.slug, recordType), + eq(schema.versionSchemas.versionId, versionRow.id,), + eq(schema.versionSchemas.slug, recordType,), ), ) - .limit(1); + .limit(1,) return c.json({ type: 'redirect' as const, @@ -184,7 +184,7 @@ export async function resolve(c: Context) { type: 'record', who: ownerName, what: `${recordType} ${recordId} in ${collectionName}`, - when: formatErcDate(versionRow.createdAt), + when: formatErcDate(versionRow.createdAt,), where: arkUrl, naan: resolvedNaan, collectionName, @@ -198,17 +198,17 @@ export async function resolve(c: Context) { createdAt: versionRow.createdAt, arkUrl, }, - }); + },) } // --- Collection / version resolution --- if (collRow.customUrl) { const what = versionRow ? `${collectionName} ${versionRow.semver}` - : collectionName; + : collectionName const when = versionRow - ? formatErcDate(versionRow.createdAt) - : '(:unkn)'; + ? formatErcDate(versionRow.createdAt,) + : '(:unkn)' return c.json({ type: 'redirect' as const, url: collRow.customUrl, @@ -230,11 +230,11 @@ export async function resolve(c: Context) { createdAt: versionRow?.createdAt, arkUrl, }, - }); + },) } if (version !== undefined && versionRow) { - const url = `/${ownerSlug}/${collectionSlug}/v/${versionRow.number}`; + const url = `/${ownerSlug}/${collectionSlug}/v/${versionRow.number}` return c.json({ type: 'redirect' as const, url, @@ -242,7 +242,7 @@ export async function resolve(c: Context) { type: 'version', who: ownerName, what: `${collectionName} ${versionRow.semver}`, - when: formatErcDate(versionRow.createdAt), + when: formatErcDate(versionRow.createdAt,), where: arkUrl, naan: resolvedNaan, collectionName, @@ -256,11 +256,11 @@ export async function resolve(c: Context) { createdAt: versionRow.createdAt, arkUrl, }, - }); + },) } // Default: redirect to collection overview - const url = `/${ownerSlug}/${collectionSlug}`; + const url = `/${ownerSlug}/${collectionSlug}` return c.json({ type: 'redirect' as const, url, @@ -268,7 +268,7 @@ export async function resolve(c: Context) { type: 'collection', who: ownerName, what: collectionName, - when: versionRow ? formatErcDate(versionRow.createdAt) : '(:unkn)', + when: versionRow ? formatErcDate(versionRow.createdAt,) : '(:unkn)', where: arkUrl, naan: resolvedNaan, collectionName, @@ -278,234 +278,239 @@ export async function resolve(c: Context) { createdAt: versionRow?.createdAt, arkUrl, }, - }); + },) } // --- Collection ARK settings --- -export async function getArk(c: Context) { - const owner = c.req.param('owner')!; - const slug = c.req.param('slug')!; +export async function getArk(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! - const [coll] = await db + const [coll,] = await db .select({ id: schema.collections.id, accountId: schema.collections.accountId, ownerNaan: schema.accounts.arkNaan, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - if (!coll) return c.json({ error: 'Collection not found' }, 404); + },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) + if (!coll) return c.json({ error: 'Collection not found', }, 404,) // Must be owner/member - const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!); - if (!hasAccess) return c.json({ error: 'Forbidden' }, 403); + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId',)!,) + if (!hasAccess) return c.json({ error: 'Forbidden', }, 403,) - const naan = coll.ownerNaan ?? DEFAULT_NAAN; + const naan = coll.ownerNaan ?? DEFAULT_NAAN - const [arkRow] = await db + const [arkRow,] = await db .select({ arkId: schema.arkCollections.arkId, enabled: schema.arkCollections.enabled, customUrl: schema.arkCollections.customUrl, shoulder: schema.arkShoulders.shoulder, - }) - .from(schema.arkCollections) + },) + .from(schema.arkCollections,) .innerJoin( schema.arkShoulders, - eq(schema.arkShoulders.accountId, coll.accountId), + eq(schema.arkShoulders.accountId, coll.accountId,), ) - .where(eq(schema.arkCollections.collectionId, coll.id)) - .limit(1); + .where(eq(schema.arkCollections.collectionId, coll.id,),) + .limit(1,) if (!arkRow) { - return c.json({ enabled: false, customUrl: null, arkUrl: null, shoulder: null, arkId: null }); + return c.json({ enabled: false, customUrl: null, arkUrl: null, shoulder: null, arkId: null, },) } - const arkUrl = buildArkUrl(naan, arkRow.shoulder, arkRow.arkId); - return c.json({ enabled: arkRow.enabled, customUrl: arkRow.customUrl, arkUrl, shoulder: arkRow.shoulder, arkId: arkRow.arkId }); + const arkUrl = buildArkUrl(naan, arkRow.shoulder, arkRow.arkId,) + return c.json({ + enabled: arkRow.enabled, + customUrl: arkRow.customUrl, + arkUrl, + shoulder: arkRow.shoulder, + arkId: arkRow.arkId, + },) } -export async function updateArk(c: Context) { - const owner = c.req.param('owner')!; - const slug = c.req.param('slug')!; - const { enabled, customUrl } = await c.req.json(); +export async function updateArk(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const { enabled, customUrl, } = await c.req.json() - const [coll] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - if (!coll) return c.json({ error: 'Collection not found' }, 404); + const [coll,] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId, },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) + if (!coll) return c.json({ error: 'Collection not found', }, 404,) - const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!); - if (!hasAccess) return c.json({ error: 'Forbidden' }, 403); + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId',)!,) + if (!hasAccess) return c.json({ error: 'Forbidden', }, 403,) - const [existing] = await db - .select({ collectionId: schema.arkCollections.collectionId }) - .from(schema.arkCollections) - .where(eq(schema.arkCollections.collectionId, coll.id)) - .limit(1); + const [existing,] = await db + .select({ collectionId: schema.arkCollections.collectionId, },) + .from(schema.arkCollections,) + .where(eq(schema.arkCollections.collectionId, coll.id,),) + .limit(1,) if (!existing) { // Collection predates ARK tables — mint now - await getOrMintShoulder(coll.accountId); - const arkId = collectionToArkId(coll.id); - await db.insert(schema.arkCollections).values({ + await getOrMintShoulder(coll.accountId,) + const arkId = collectionToArkId(coll.id,) + await db.insert(schema.arkCollections,).values({ collectionId: coll.id, arkId, enabled: enabled ?? true, customUrl: customUrl ?? null, - }); + },) } else { - const updates: Record = {}; - if (enabled !== undefined) updates.enabled = enabled; - if (customUrl !== undefined) updates.customUrl = customUrl ?? null; - if (Object.keys(updates).length > 0) { + const updates: Record = {} + if (enabled !== undefined) updates.enabled = enabled + if (customUrl !== undefined) updates.customUrl = customUrl ?? null + if (Object.keys(updates,).length > 0) { await db - .update(schema.arkCollections) - .set(updates) - .where(eq(schema.arkCollections.collectionId, coll.id)); + .update(schema.arkCollections,) + .set(updates,) + .where(eq(schema.arkCollections.collectionId, coll.id,),) } } - return c.json({ ok: true }); + return c.json({ ok: true, },) } // --- Record type ARK settings --- -export async function getArkRecordTypes(c: Context) { - const owner = c.req.param('owner')!; - const slug = c.req.param('slug')!; +export async function getArkRecordTypes(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! - const [coll] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - if (!coll) return c.json({ error: 'Collection not found' }, 404); + const [coll,] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId, },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) + if (!coll) return c.json({ error: 'Collection not found', }, 404,) - const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!); - if (!hasAccess) return c.json({ error: 'Forbidden' }, 403); + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId',)!,) + if (!hasAccess) return c.json({ error: 'Forbidden', }, 403,) const rows = await db .select({ recordType: schema.arkRecordTypes.recordType, redirectUrlField: schema.arkRecordTypes.redirectUrlField, - }) - .from(schema.arkRecordTypes) - .where(eq(schema.arkRecordTypes.collectionId, coll.id)); + },) + .from(schema.arkRecordTypes,) + .where(eq(schema.arkRecordTypes.collectionId, coll.id,),) - return c.json(rows); + return c.json(rows,) } -export async function updateArkRecordTypes(c: Context) { - const owner = c.req.param('owner')!; - const slug = c.req.param('slug')!; - const { recordType, redirectUrlField } = await c.req.json(); +export async function updateArkRecordTypes(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const { recordType, redirectUrlField, } = await c.req.json() - if (!recordType) return c.json({ error: 'recordType required' }, 400); + if (!recordType) return c.json({ error: 'recordType required', }, 400,) - const [coll] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - if (!coll) return c.json({ error: 'Collection not found' }, 404); + const [coll,] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId, },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) + if (!coll) return c.json({ error: 'Collection not found', }, 404,) - const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!); - if (!hasAccess) return c.json({ error: 'Forbidden' }, 403); + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId',)!,) + if (!hasAccess) return c.json({ error: 'Forbidden', }, 403,) if (redirectUrlField === null) { await db - .delete(schema.arkRecordTypes) + .delete(schema.arkRecordTypes,) .where( and( - eq(schema.arkRecordTypes.collectionId, coll.id), - eq(schema.arkRecordTypes.recordType, recordType), + eq(schema.arkRecordTypes.collectionId, coll.id,), + eq(schema.arkRecordTypes.recordType, recordType,), ), - ); + ) } else { await db - .insert(schema.arkRecordTypes) - .values({ collectionId: coll.id, recordType, redirectUrlField }) + .insert(schema.arkRecordTypes,) + .values({ collectionId: coll.id, recordType, redirectUrlField, },) .onConflictDoUpdate({ - target: [schema.arkRecordTypes.collectionId, schema.arkRecordTypes.recordType], - set: { redirectUrlField }, - }); + target: [schema.arkRecordTypes.collectionId, schema.arkRecordTypes.recordType,], + set: { redirectUrlField, }, + },) } - return c.json({ ok: true }); + return c.json({ ok: true, },) } // --- Org ARK NAAN --- -export async function updateAccountArk(c: Context) { - const slug = c.req.param('slug')!; - const { naan } = await c.req.json(); +export async function updateAccountArk(c: Context,) { + const slug = c.req.param('slug',)! + const { naan, } = await c.req.json() - if (naan !== null && !/^\d{1,16}$/.test(naan)) { - return c.json({ error: 'NAAN must be numeric (up to 16 digits)' }, 400); + if (naan !== null && !/^\d{1,16}$/.test(naan,)) { + return c.json({ error: 'NAAN must be numeric (up to 16 digits)', }, 400,) } - const [account] = await db - .select({ id: schema.accounts.id, type: schema.accounts.type }) - .from(schema.accounts) - .where(eq(schema.accounts.slug, slug)) - .limit(1); - if (!account) return c.json({ error: 'Account not found' }, 404); + const [account,] = await db + .select({ id: schema.accounts.id, type: schema.accounts.type, },) + .from(schema.accounts,) + .where(eq(schema.accounts.slug, slug,),) + .limit(1,) + if (!account) return c.json({ error: 'Account not found', }, 404,) // Must be owner/admin of the org (or the user themselves) if (account.type === 'org') { - const [membership] = await db - .select({ role: schema.orgMemberships.role }) - .from(schema.orgMemberships) + const [membership,] = await db + .select({ role: schema.orgMemberships.role, },) + .from(schema.orgMemberships,) .where( and( - eq(schema.orgMemberships.orgId, account.id), - eq(schema.orgMemberships.userId, c.get('accountId')!), + eq(schema.orgMemberships.orgId, account.id,), + eq(schema.orgMemberships.userId, c.get('accountId',)!,), ), ) - .limit(1); + .limit(1,) if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) { - return c.json({ error: 'Forbidden' }, 403); + return c.json({ error: 'Forbidden', }, 403,) } - } else if (account.id !== c.get('accountId')) { - return c.json({ error: 'Forbidden' }, 403); + } else if (account.id !== c.get('accountId',)) { + return c.json({ error: 'Forbidden', }, 403,) } - await db.update(schema.accounts).set({ arkNaan: naan }).where(eq(schema.accounts.id, account.id)); - return c.json({ ok: true }); + await db.update(schema.accounts,).set({ arkNaan: naan, },).where(eq(schema.accounts.id, account.id,),) + return c.json({ ok: true, },) } // --- Helpers --- -async function checkCollectionAccess(ownerAccountId: string, requestAccountId: string): Promise { - const [account] = await db - .select({ id: schema.accounts.id, type: schema.accounts.type }) - .from(schema.accounts) - .where(eq(schema.accounts.id, ownerAccountId)) - .limit(1); - if (!account) return false; - if (account.id === requestAccountId) return true; +async function checkCollectionAccess(ownerAccountId: string, requestAccountId: string,): Promise { + const [account,] = await db + .select({ id: schema.accounts.id, type: schema.accounts.type, },) + .from(schema.accounts,) + .where(eq(schema.accounts.id, ownerAccountId,),) + .limit(1,) + if (!account) return false + if (account.id === requestAccountId) return true if (account.type === 'org') { - const [membership] = await db - .select({ role: schema.orgMemberships.role }) - .from(schema.orgMemberships) + const [membership,] = await db + .select({ role: schema.orgMemberships.role, },) + .from(schema.orgMemberships,) .where( and( - eq(schema.orgMemberships.orgId, account.id), - eq(schema.orgMemberships.userId, requestAccountId), + eq(schema.orgMemberships.orgId, account.id,), + eq(schema.orgMemberships.userId, requestAccountId,), ), ) - .limit(1); - return !!membership; + .limit(1,) + return !!membership } - return false; + return false } - diff --git a/src/api/auth.server.ts b/src/api/auth.server.ts index 7746385..6de4056 100644 --- a/src/api/auth.server.ts +++ b/src/api/auth.server.ts @@ -1,9 +1,9 @@ -import type { Context, MiddlewareHandler } from 'hono' -import { createMiddleware } from 'hono/factory' -import { eq } from 'drizzle-orm' -import { db, schema } from '../db/client.server.js' import bcrypt from 'bcrypt' -import { getCookie, setCookie, deleteCookie } from 'hono/cookie' +import { eq, } from 'drizzle-orm' +import type { Context, MiddlewareHandler, } from 'hono' +import { deleteCookie, getCookie, setCookie, } from 'hono/cookie' +import { createMiddleware, } from 'hono/factory' +import { db, schema, } from '../db/client.server.js' export type AuthEnv = { Variables: { @@ -21,65 +21,65 @@ const publicPaths = new Set([ '/api/accounts/forgot-password', '/api/accounts/reset-password', '/api/query/generate-sql', -]) +],) const internalToken = process.env.INTERNAL_API_TOKEN ?? 'internal-dev-token' const sessionSecret = process.env.SESSION_SECRET ?? 'dev-secret-change-me' -export const authMiddleware = createMiddleware(async (c, next) => { +export const authMiddleware = createMiddleware(async (c, next,) => { // Internal service calls - const internalHeader = c.req.header('x-internal-token') + const internalHeader = c.req.header('x-internal-token',) if (internalHeader === internalToken) { - c.set('apiKeyScope', 'read') + c.set('apiKeyScope', 'read',) return next() } // API key auth via Bearer token - const auth = c.req.header('authorization') - if (auth?.startsWith('Bearer ')) { - const token = auth.slice(7) - const keys = await db.select().from(schema.apiKeys) + const auth = c.req.header('authorization',) + if (auth?.startsWith('Bearer ',)) { + const token = auth.slice(7,) + const keys = await db.select().from(schema.apiKeys,) let matched = false for (const key of keys) { - const match = await bcrypt.compare(token, key.keyHash) + const match = await bcrypt.compare(token, key.keyHash,) if (match) { - c.set('accountId', key.accountId) - c.set('apiKeyScope', key.scope as 'read' | 'write' | 'admin') - c.set('apiKeyCollectionId', key.collectionId) + c.set('accountId', key.accountId,) + c.set('apiKeyScope', key.scope as 'read' | 'write' | 'admin',) + c.set('apiKeyCollectionId', key.collectionId,) await db - .update(schema.apiKeys) - .set({ lastUsedAt: new Date() }) - .where(eq(schema.apiKeys.id, key.id)) + .update(schema.apiKeys,) + .set({ lastUsedAt: new Date(), },) + .where(eq(schema.apiKeys.id, key.id,),) matched = true break } } if (!matched) { - return c.json({ error: 'Invalid API key', statusCode: 401 }, 401) + return c.json({ error: 'Invalid API key', statusCode: 401, }, 401,) } return next() } // Session cookie auth - const sessionCookie = getCookie(c, 'session') + const sessionCookie = getCookie(c, 'session',) if (sessionCookie) { try { // Try to parse as signed cookie (value.signature format) let sessionId = sessionCookie - const dotIdx = sessionCookie.lastIndexOf('.') + const dotIdx = sessionCookie.lastIndexOf('.',) if (dotIdx > 0) { - sessionId = sessionCookie.slice(0, dotIdx) + sessionId = sessionCookie.slice(0, dotIdx,) } if (sessionId) { - const [session] = await db + const [session,] = await db .select() - .from(schema.sessions) - .where(eq(schema.sessions.id, sessionId)) - .limit(1) - if (session && new Date(session.expiresAt) > new Date()) { - c.set('sessionUserId', session.userId) - c.set('accountId', session.userId) - c.set('apiKeyScope', 'admin') + .from(schema.sessions,) + .where(eq(schema.sessions.id, sessionId,),) + .limit(1,) + if (session && new Date(session.expiresAt,) > new Date()) { + c.set('sessionUserId', session.userId,) + c.set('accountId', session.userId,) + c.set('apiKeyScope', 'admin',) } } } catch { @@ -91,41 +91,41 @@ export const authMiddleware = createMiddleware(async (c, next) => { if (c.req.method === 'GET') return next() // All writes require auth, except public paths - if (!c.get('accountId')) { - const path = new URL(c.req.url).pathname - if (publicPaths.has(path)) return next() - return c.json({ error: 'Authentication required', statusCode: 401 }, 401) + if (!c.get('accountId',)) { + const path = new URL(c.req.url,).pathname + if (publicPaths.has(path,)) return next() + return c.json({ error: 'Authentication required', statusCode: 401, }, 401,) } return next() -}) +},) -export function requireAuth(scope?: 'read' | 'write' | 'admin'): MiddlewareHandler { - return async (c, next) => { - if (!c.get('accountId')) { - return c.json({ error: 'Authentication required', statusCode: 401 }, 401) +export function requireAuth(scope?: 'read' | 'write' | 'admin',): MiddlewareHandler { + return async (c, next,) => { + if (!c.get('accountId',)) { + return c.json({ error: 'Authentication required', statusCode: 401, }, 401,) } - if (scope === 'admin' && c.get('apiKeyScope') !== 'admin') { - return c.json({ error: 'Admin access required', statusCode: 403 }, 403) + if (scope === 'admin' && c.get('apiKeyScope',) !== 'admin') { + return c.json({ error: 'Admin access required', statusCode: 403, }, 403,) } - if (scope === 'write' && c.get('apiKeyScope') === 'read') { - return c.json({ error: 'Write access required', statusCode: 403 }, 403) + if (scope === 'write' && c.get('apiKeyScope',) === 'read') { + return c.json({ error: 'Write access required', statusCode: 403, }, 403,) } return next() } } // Helper to set signed session cookie -export function setSessionCookie(c: Context, sessionId: string) { +export function setSessionCookie(c: Context, sessionId: string,) { setCookie(c, 'session', sessionId, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax', path: '/', maxAge: 30 * 24 * 60 * 60, // 30 days - }) + },) } -export function clearSessionCookie(c: Context) { - deleteCookie(c, 'session', { path: '/' }) +export function clearSessionCookie(c: Context,) { + deleteCookie(c, 'session', { path: '/', },) } diff --git a/src/api/collections.ts b/src/api/collections.ts index 4b7d8ca..b43f6b6 100644 --- a/src/api/collections.ts +++ b/src/api/collections.ts @@ -1,30 +1,30 @@ -import type { Context } from 'hono' -import { stream } from "hono/streaming"; -import { eq, and, ilike, or, sql } from "drizzle-orm"; -import { db, schema } from "../db/client.server.js"; -import { type AuthEnv } from "./auth.server.js"; -import { v4 as uuidv4 } from "uuid"; -import { pack as tarPack } from "tar-stream"; -import { createGzip } from "node:zlib"; -import { downloadFromS3 } from "../lib/s3.js"; -import { DEFAULT_NAAN, collectionToArkId, getOrMintShoulder, buildArkUrl } from "../lib/ark.js"; +import { and, eq, ilike, or, sql, } from 'drizzle-orm' +import type { Context, } from 'hono' +import { stream, } from 'hono/streaming' +import { createGzip, } from 'node:zlib' +import { pack as tarPack, } from 'tar-stream' +import { v4 as uuidv4, } from 'uuid' +import { db, schema, } from '../db/client.server.js' +import { buildArkUrl, collectionToArkId, DEFAULT_NAAN, getOrMintShoulder, } from '../lib/ark.js' +import { downloadFromS3, } from '../lib/s3.js' +import { type AuthEnv, } from './auth.server.js' // Browse public collections -export async function list(c: Context) { - const q = c.req.query("q"); - const limit = c.req.query("limit"); - const offset = c.req.query("offset"); - const take = Math.min(parseInt(limit ?? "50", 10), 100); - const skip = parseInt(offset ?? "0", 10); - - const conditions = [eq(schema.collections.public, true)]; +export async function list(c: Context,) { + const q = c.req.query('q',) + const limit = c.req.query('limit',) + const offset = c.req.query('offset',) + const take = Math.min(parseInt(limit ?? '50', 10,), 100,) + const skip = parseInt(offset ?? '0', 10,) + + const conditions = [eq(schema.collections.public, true,),] if (q) { conditions.push( or( - ilike(schema.collections.name, `%${q}%`), - ilike(schema.collections.description, `%${q}%`), + ilike(schema.collections.name, `%${q}%`,), + ilike(schema.collections.description, `%${q}%`,), )!, - ); + ) } const results = await db @@ -37,104 +37,104 @@ export async function list(c: Context) { ownerName: schema.accounts.displayName, createdAt: schema.collections.createdAt, updatedAt: schema.collections.updatedAt, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(...conditions)) - .limit(take) - .offset(skip) - .orderBy(schema.collections.updatedAt); - - return c.json(results); + },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(...conditions,),) + .limit(take,) + .offset(skip,) + .orderBy(schema.collections.updatedAt,) + + return c.json(results,) } // Create collection -export async function create(c: Context) { - const owner = c.req.param("owner")!; - const { slug, name, description, public: isPublic } = await c.req.json<{ - slug: string; - name: string; - description?: string; - public?: boolean; - }>(); +export async function create(c: Context,) { + const owner = c.req.param('owner',)! + const { slug, name, description, public: isPublic, } = await c.req.json<{ + slug: string + name: string + description?: string + public?: boolean + }>() // Resolve owner account - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, owner)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.slug, owner,),) + .limit(1,) if (!account) { - return c.json({ error: "Account not found", statusCode: 404 }, 404); + return c.json({ error: 'Account not found', statusCode: 404, }, 404,) } // Check permission: user must own the account or be a member of the org - if (account.type === "user" && account.id !== c.get("accountId")) { - return c.json({ error: "Forbidden", statusCode: 403 }, 403); + if (account.type === 'user' && account.id !== c.get('accountId',)) { + return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) } - if (account.type === "org") { - const [membership] = await db + if (account.type === 'org') { + const [membership,] = await db .select() - .from(schema.orgMemberships) + .from(schema.orgMemberships,) .where( and( - eq(schema.orgMemberships.orgId, account.id), - eq(schema.orgMemberships.userId, c.get("accountId")!), + eq(schema.orgMemberships.orgId, account.id,), + eq(schema.orgMemberships.userId, c.get('accountId',)!,), ), ) - .limit(1); + .limit(1,) if (!membership) { - return c.json({ error: "Forbidden", statusCode: 403 }, 403); + return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) } } // Check for existing collection with same slug under this owner - const [existing] = await db - .select({ id: schema.collections.id }) - .from(schema.collections) + const [existing,] = await db + .select({ id: schema.collections.id, },) + .from(schema.collections,) .where( and( - eq(schema.collections.accountId, account.id), - eq(schema.collections.slug, slug), + eq(schema.collections.accountId, account.id,), + eq(schema.collections.slug, slug,), ), ) - .limit(1); + .limit(1,) if (existing) { - return c.json({ error: "Collection already exists", statusCode: 409 }, 409); + return c.json({ error: 'Collection already exists', statusCode: 409, }, 409,) } - const id = uuidv4(); - await db.insert(schema.collections).values({ + const id = uuidv4() + await db.insert(schema.collections,).values({ id, accountId: account.id, slug, name, description: description ?? null, public: isPublic ?? false, - }); + },) // Auto-mint ARK for the new collection try { - const shoulder = await getOrMintShoulder(account.id); - const arkId = collectionToArkId(id); - await db.insert(schema.arkCollections).values({ collectionId: id, arkId, enabled: true }); - const naan = account.arkNaan ?? DEFAULT_NAAN; - const arkUrl = buildArkUrl(naan, shoulder, arkId); - return c.json({ id, owner, slug, name, ark: arkUrl }, 201); + const shoulder = await getOrMintShoulder(account.id,) + const arkId = collectionToArkId(id,) + await db.insert(schema.arkCollections,).values({ collectionId: id, arkId, enabled: true, },) + const naan = account.arkNaan ?? DEFAULT_NAAN + const arkUrl = buildArkUrl(naan, shoulder, arkId,) + return c.json({ id, owner, slug, name, ark: arkUrl, }, 201,) } catch { // ARK minting failure is non-fatal - return c.json({ id, owner, slug, name }, 201); + return c.json({ id, owner, slug, name, }, 201,) } } // Get collection -export async function get(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; +export async function get(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! - const [result] = await db + const [result,] = await db .select({ id: schema.collections.id, slug: schema.collections.slug, @@ -146,50 +146,50 @@ export async function get(c: Context) { ownerType: schema.accounts.type, createdAt: schema.collections.createdAt, updatedAt: schema.collections.updatedAt, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); + },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) if (!result) { - return c.json({ error: "Collection not found", statusCode: 404 }, 404); + return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) } - if (!result.public && c.get("accountId") !== result.id) { + if (!result.public && c.get('accountId',) !== result.id) { // Check if user owns or is member of the owning account - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, owner)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.slug, owner,),) + .limit(1,) if (!account) { - return c.json({ error: "Collection not found", statusCode: 404 }, 404); + return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) } - let hasAccess = account.id === c.get("accountId"); - if (!hasAccess && account.type === "org") { - const [membership] = await db + let hasAccess = account.id === c.get('accountId',) + if (!hasAccess && account.type === 'org') { + const [membership,] = await db .select() - .from(schema.orgMemberships) + .from(schema.orgMemberships,) .where( and( - eq(schema.orgMemberships.orgId, account.id), - eq(schema.orgMemberships.userId, c.get("accountId")!), + eq(schema.orgMemberships.orgId, account.id,), + eq(schema.orgMemberships.userId, c.get('accountId',)!,), ), ) - .limit(1); - hasAccess = !!membership; + .limit(1,) + hasAccess = !!membership } if (!hasAccess) { - return c.json({ error: "Collection not found", statusCode: 404 }, 404); + return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) } } // Get latest version info - const [latestVersion] = await db + const [latestVersion,] = await db .select({ id: schema.versions.id, number: schema.versions.number, @@ -200,151 +200,151 @@ export async function get(c: Context) { createdAt: schema.versions.createdAt, message: schema.versions.message, readme: schema.versions.readme, - }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, result.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); + },) + .from(schema.versions,) + .where(eq(schema.versions.collectionId, result.id,),) + .orderBy(sql`${schema.versions.number} desc`,) + .limit(1,) // Get per-type record counts for latest version - let typeCounts: { type: string; count: number }[] = []; + let typeCounts: { type: string; count: number }[] = [] if (latestVersion) { const rows = await db .select({ type: schema.records.type, count: sql`count(*)::int`, - }) - .from(schema.records) - .where(eq(schema.records.versionId, latestVersion.id)) - .groupBy(schema.records.type); - typeCounts = rows.map((r) => ({ type: r.type, count: r.count })); + },) + .from(schema.records,) + .where(eq(schema.records.versionId, latestVersion.id,),) + .groupBy(schema.records.type,) + typeCounts = rows.map((r,) => ({ type: r.type, count: r.count, })) } // Fetch ARK URL if enabled - let ark: string | null = null; + let ark: string | null = null try { - const [arkRow] = await db + const [arkRow,] = await db .select({ arkId: schema.arkCollections.arkId, enabled: schema.arkCollections.enabled, shoulder: schema.arkShoulders.shoulder, ownerNaan: schema.accounts.arkNaan, - }) - .from(schema.arkCollections) - .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id)) - .where(eq(schema.arkCollections.collectionId, result.id)) - .limit(1); + },) + .from(schema.arkCollections,) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id,),) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id,),) + .where(eq(schema.arkCollections.collectionId, result.id,),) + .limit(1,) if (arkRow?.enabled) { - ark = buildArkUrl(arkRow.ownerNaan ?? DEFAULT_NAAN, arkRow.shoulder, arkRow.arkId); + ark = buildArkUrl(arkRow.ownerNaan ?? DEFAULT_NAAN, arkRow.shoulder, arkRow.arkId,) } } catch { // Non-fatal } - const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined }; - return c.json({ ...result, ark, latestVersion: latestVersion ? { ...latestVersionData, typeCounts } : null }); + const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined, } + return c.json({ ...result, ark, latestVersion: latestVersion ? { ...latestVersionData, typeCounts, } : null, },) } // Update collection -export async function update(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; +export async function update(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! const updates = await c.req.json<{ - name?: string; - description?: string; - public?: boolean; - }>(); + name?: string + description?: string + public?: boolean + }>() - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, owner)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.slug, owner,),) + .limit(1,) if (!account) { - return c.json({ error: "Not found", statusCode: 404 }, 404); + return c.json({ error: 'Not found', statusCode: 404, }, 404,) } - const [collection] = await db + const [collection,] = await db .select() - .from(schema.collections) - .where(and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, slug))) - .limit(1); + .from(schema.collections,) + .where(and(eq(schema.collections.accountId, account.id,), eq(schema.collections.slug, slug,),),) + .limit(1,) if (!collection) { - return c.json({ error: "Not found", statusCode: 404 }, 404); + return c.json({ error: 'Not found', statusCode: 404, }, 404,) } await db - .update(schema.collections) - .set({ ...updates, updatedAt: new Date() }) - .where(eq(schema.collections.id, collection.id)); + .update(schema.collections,) + .set({ ...updates, updatedAt: new Date(), },) + .where(eq(schema.collections.id, collection.id,),) - return c.json({ ok: true }); + return c.json({ ok: true, },) } // Delete collection -export async function remove(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; +export async function remove(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, owner)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.slug, owner,),) + .limit(1,) if (!account) { - return c.json({ error: "Not found", statusCode: 404 }, 404); + return c.json({ error: 'Not found', statusCode: 404, }, 404,) } - const [collection] = await db + const [collection,] = await db .select() - .from(schema.collections) - .where(and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, slug))) - .limit(1); + .from(schema.collections,) + .where(and(eq(schema.collections.accountId, account.id,), eq(schema.collections.slug, slug,),),) + .limit(1,) if (!collection) { - return c.json({ error: "Not found", statusCode: 404 }, 404); + return c.json({ error: 'Not found', statusCode: 404, }, 404,) } - await db.delete(schema.collections).where(eq(schema.collections.id, collection.id)); - return c.json({ ok: true }); + await db.delete(schema.collections,).where(eq(schema.collections.id, collection.id,),) + return c.json({ ok: true, },) } // List collections for an account -export async function listByOwner(c: Context) { - const owner = c.req.param("owner")!; +export async function listByOwner(c: Context,) { + const owner = c.req.param('owner',)! - const [account] = await db + const [account,] = await db .select() - .from(schema.accounts) - .where(eq(schema.accounts.slug, owner)) - .limit(1); + .from(schema.accounts,) + .where(eq(schema.accounts.slug, owner,),) + .limit(1,) - if (!account) return c.json([]); + if (!account) return c.json([],) // Check if the requester owns this account or is an org member - let hasFullAccess = c.get("accountId") === account.id; - if (!hasFullAccess && account.type === "org" && c.get("accountId")) { - const [membership] = await db + let hasFullAccess = c.get('accountId',) === account.id + if (!hasFullAccess && account.type === 'org' && c.get('accountId',)) { + const [membership,] = await db .select() - .from(schema.orgMemberships) + .from(schema.orgMemberships,) .where( and( - eq(schema.orgMemberships.orgId, account.id), - eq(schema.orgMemberships.userId, c.get("accountId")!), + eq(schema.orgMemberships.orgId, account.id,), + eq(schema.orgMemberships.userId, c.get('accountId',)!,), ), ) - .limit(1); - hasFullAccess = !!membership; + .limit(1,) + hasFullAccess = !!membership } - const conditions = [eq(schema.collections.accountId, account.id)]; + const conditions = [eq(schema.collections.accountId, account.id,),] if (!hasFullAccess) { - conditions.push(eq(schema.collections.public, true)); + conditions.push(eq(schema.collections.public, true,),) } const results = await db @@ -356,22 +356,22 @@ export async function listByOwner(c: Context) { public: schema.collections.public, createdAt: schema.collections.createdAt, updatedAt: schema.collections.updatedAt, - }) - .from(schema.collections) - .where(and(...conditions)) - .orderBy(schema.collections.updatedAt); + },) + .from(schema.collections,) + .where(and(...conditions,),) + .orderBy(schema.collections.updatedAt,) - return c.json(results); + return c.json(results,) } // Export collection as .tar.gz archive -export async function exportArchive(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const versionParam = c.req.query("version"); +export async function exportArchive(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const versionParam = c.req.query('version',) // Resolve collection - const [collection] = await db + const [collection,] = await db .select({ id: schema.collections.id, slug: schema.collections.slug, @@ -379,35 +379,35 @@ export async function exportArchive(c: Context) { description: schema.collections.description, public: schema.collections.public, accountId: schema.collections.accountId, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); + },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) if (!collection) { - return c.json({ error: "Collection not found", statusCode: 404 }, 404); + return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) } - if (!collection.public && c.get("accountId") !== collection.accountId) { - return c.json({ error: "Collection not found", statusCode: 404 }, 404); + if (!collection.public && c.get('accountId',) !== collection.accountId) { + return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) } // Resolve version (latest if not specified) - const versionConditions = [eq(schema.versions.collectionId, collection.id)]; + const versionConditions = [eq(schema.versions.collectionId, collection.id,),] if (versionParam) { - versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10))); + versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10,),),) } - const [version] = await db + const [version,] = await db .select() - .from(schema.versions) - .where(and(...versionConditions)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); + .from(schema.versions,) + .where(and(...versionConditions,),) + .orderBy(sql`${schema.versions.number} desc`,) + .limit(1,) if (!version) { - return c.json({ error: "No versions found", statusCode: 404 }, 404); + return c.json({ error: 'No versions found', statusCode: 404, }, 404,) } // Fetch records and files for this version @@ -416,9 +416,9 @@ export async function exportArchive(c: Context) { recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data, - }) - .from(schema.records) - .where(eq(schema.records.versionId, version.id)); + },) + .from(schema.records,) + .where(eq(schema.records.versionId, version.id,),) const versionFiles = await db .select({ @@ -426,28 +426,28 @@ export async function exportArchive(c: Context) { size: schema.files.size, mimeType: schema.files.mimeType, storageKey: schema.files.storageKey, - }) - .from(schema.versionFiles) - .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) - .where(eq(schema.versionFiles.versionId, version.id)); + },) + .from(schema.versionFiles,) + .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash,),) + .where(eq(schema.versionFiles.versionId, version.id,),) // Load schemas for this version const versionSchemaEntries = await db .select({ slug: schema.versionSchemas.slug, schemaBody: schema.schemas.schema, - }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where(eq(schema.versionSchemas.versionId, version.id)); + },) + .from(schema.versionSchemas,) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id,),) + .where(eq(schema.versionSchemas.versionId, version.id,),) const schemasMap = Object.fromEntries( - versionSchemaEntries.map((e) => [e.slug, e.schemaBody]), - ); + versionSchemaEntries.map((e,) => [e.slug, e.schemaBody,]), + ) // Add manifest.json const manifest = { - collection: { owner, slug, name: collection.name, description: collection.description }, + collection: { owner, slug, name: collection.name, description: collection.description, }, version: { number: version.number, semver: version.semver, @@ -459,65 +459,62 @@ export async function exportArchive(c: Context) { createdAt: version.createdAt, }, schemas: schemasMap, - }; + } // Build tar.gz stream - const pack = tarPack(); - const gzip = createGzip(); + const pack = tarPack() + const gzip = createGzip() - const filename = `${owner}-${slug}-v${version.number}.tar.gz`; + const filename = `${owner}-${slug}-v${version.number}.tar.gz` // Add manifest - const manifestBuf = Buffer.from(JSON.stringify(manifest, null, 2)); - pack.entry({ name: "manifest.json", size: manifestBuf.length }, manifestBuf); + const manifestBuf = Buffer.from(JSON.stringify(manifest, null, 2,),) + pack.entry({ name: 'manifest.json', size: manifestBuf.length, }, manifestBuf,) // Add records as NDJSON grouped by type - const recordsByType = new Map(); + const recordsByType = new Map() for (const rec of records) { - const existing = recordsByType.get(rec.type) ?? []; - existing.push(rec); - recordsByType.set(rec.type, existing); + const existing = recordsByType.get(rec.type,) ?? [] + existing.push(rec,) + recordsByType.set(rec.type, existing,) } - for (const [type, typeRecords] of recordsByType) { - const lines = typeRecords.map((r) => - JSON.stringify({ id: r.recordId, type: r.type, data: r.data }), - ); - const buf = Buffer.from(lines.join("\n") + "\n"); - pack.entry({ name: `records/${type}.ndjson`, size: buf.length }, buf); + for (const [type, typeRecords,] of recordsByType) { + const lines = typeRecords.map((r,) => JSON.stringify({ id: r.recordId, type: r.type, data: r.data, },)) + const buf = Buffer.from(lines.join('\n',) + '\n',) + pack.entry({ name: `records/${type}.ndjson`, size: buf.length, }, buf,) } // Add files for (const file of versionFiles) { try { - const fileBuffer = await downloadFromS3(file.storageKey); - pack.entry({ name: `files/${file.hash}`, size: fileBuffer.length }, fileBuffer); + const fileBuffer = await downloadFromS3(file.storageKey,) + pack.entry({ name: `files/${file.hash}`, size: fileBuffer.length, }, fileBuffer,) } catch { // Skip files that can't be downloaded (shouldn't happen in normal operation) } } - pack.finalize(); + pack.finalize() // Pipe tar → gzip and collect into a ReadableStream - const outputStream = pack.pipe(gzip); + const outputStream = pack.pipe(gzip,) const readableStream = new ReadableStream({ - start(controller) { - outputStream.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)); - }); - outputStream.on("end", () => { - controller.close(); - }); - outputStream.on("error", (err) => { - controller.error(err); - }); + start(controller,) { + outputStream.on('data', (chunk: Buffer,) => { + controller.enqueue(new Uint8Array(chunk,),) + },) + outputStream.on('end', () => { + controller.close() + },) + outputStream.on('error', (err,) => { + controller.error(err,) + },) }, - }); + },) return c.body(readableStream, 200, { - "Content-Type": "application/gzip", - "Content-Disposition": `attachment; filename="${filename}"`, - }); + 'Content-Type': 'application/gzip', + 'Content-Disposition': `attachment; filename="${filename}"`, + },) } - diff --git a/src/api/files.ts b/src/api/files.ts index 611fe77..cae4f49 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -1,9 +1,9 @@ -import type { Context } from 'hono' -import { eq, and, sql } from "drizzle-orm"; -import { db, schema } from "../db/client.server.js"; -import { type AuthEnv } from "./auth.server.js"; -import { uploadToS3, getS3ObjectMeta } from "../lib/s3.js"; -import { createHash } from "node:crypto"; +import { and, eq, sql, } from 'drizzle-orm' +import type { Context, } from 'hono' +import { createHash, } from 'node:crypto' +import { db, schema, } from '../db/client.server.js' +import { getS3ObjectMeta, uploadToS3, } from '../lib/s3.js' +import { type AuthEnv, } from './auth.server.js' /** * Check if a file hash is referenced by any public (non-private) record @@ -16,239 +16,239 @@ async function isFilePubliclyAccessible( accountId: string | undefined, ): Promise { // Resolve collection - const [collection] = await db + const [collection,] = await db .select({ id: schema.collections.id, accountId: schema.collections.accountId, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); + },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) - if (!collection) return false; + if (!collection) return false // Owner always has access if (accountId != null && accountId === collection.accountId) { - return true; + return true } // Get the latest version - const [latest] = await db - .select({ id: schema.versions.id }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); + const [latest,] = await db + .select({ id: schema.versions.id, },) + .from(schema.versions,) + .where(eq(schema.versions.collectionId, collection.id,),) + .orderBy(sql`${schema.versions.number} desc`,) + .limit(1,) - if (!latest) return false; + if (!latest) return false // Check if file is associated with this version at all - const [vf] = await db - .select({ fileHash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) + const [vf,] = await db + .select({ fileHash: schema.versionFiles.fileHash, },) + .from(schema.versionFiles,) .where( - and(eq(schema.versionFiles.versionId, latest.id), eq(schema.versionFiles.fileHash, fileHash)), + and(eq(schema.versionFiles.versionId, latest.id,), eq(schema.versionFiles.fileHash, fileHash,),), ) - .limit(1); + .limit(1,) - if (!vf) return false; + if (!vf) return false // Load version schemas to determine private types and fields const schemaEntries = await db .select({ slug: schema.versionSchemas.slug, schemaBody: schema.schemas.schema, - }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where(eq(schema.versionSchemas.versionId, latest.id)); + },) + .from(schema.versionSchemas,) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id,),) + .where(eq(schema.versionSchemas.versionId, latest.id,),) - const privateTypes = new Set(); - const typeSchemaMap = new Map>(); + const privateTypes = new Set() + const typeSchemaMap = new Map>() for (const entry of schemaEntries) { - const body = entry.schemaBody as Record; - typeSchemaMap.set(entry.slug, body); - if (body?.private === true) privateTypes.add(entry.slug); + const body = entry.schemaBody as Record + typeSchemaMap.set(entry.slug, body,) + if (body?.private === true) privateTypes.add(entry.slug,) } // Find public records that reference this file hash // A record references a file if its data JSON contains the hash string const records = await db - .select({ type: schema.records.type, data: schema.records.data }) - .from(schema.records) + .select({ type: schema.records.type, data: schema.records.data, },) + .from(schema.records,) .where( and( - eq(schema.records.versionId, latest.id), - eq(schema.records.private, false), - sql`${schema.records.data}::text LIKE ${"%" + fileHash + "%"}`, + eq(schema.records.versionId, latest.id,), + eq(schema.records.private, false,), + sql`${schema.records.data}::text LIKE ${'%' + fileHash + '%'}`, ), ) - .limit(10); + .limit(10,) // Check if any matching record is a public type with the file in a public field for (const rec of records) { - if (privateTypes.has(rec.type)) continue; + if (privateTypes.has(rec.type,)) continue // Get private fields for this type - const typeSchema = typeSchemaMap.get(rec.type); - const typeProps = typeSchema?.properties as Record | undefined; - if (!typeProps) return true; // no schema constraints, allow + const typeSchema = typeSchemaMap.get(rec.type,) + const typeProps = typeSchema?.properties as Record | undefined + if (!typeProps) return true // no schema constraints, allow - const privateFields = new Set(); - for (const [fieldName, fieldDef] of Object.entries(typeProps)) { - if ((fieldDef as any)?.private === true) privateFields.add(fieldName); + const privateFields = new Set() + for (const [fieldName, fieldDef,] of Object.entries(typeProps,)) { + if ((fieldDef as any)?.private === true) privateFields.add(fieldName,) } // Check if the file reference is in a public field - const data = rec.data as Record; - for (const [key, val] of Object.entries(data)) { - if (privateFields.has(key)) continue; + const data = rec.data as Record + for (const [key, val,] of Object.entries(data,)) { + if (privateFields.has(key,)) continue if ( - val && - typeof val === "object" && - "$file" in val && - (val as { $file: string }).$file === `sha256:${fileHash}` + val + && typeof val === 'object' + && '$file' in val + && (val as { $file: string }).$file === `sha256:${fileHash}` ) { - return true; // found in a public field of a public record + return true // found in a public field of a public record } } } - return false; + return false } // Check if file exists -export async function headFile(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const hash = c.req.param("hash")!; - const cleanHash = hash.replace("sha256:", ""); +export async function headFile(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const hash = c.req.param('hash',)! + const cleanHash = hash.replace('sha256:', '',) - const [file] = await db + const [file,] = await db .select() - .from(schema.files) - .where(eq(schema.files.hash, cleanHash)) - .limit(1); + .from(schema.files,) + .where(eq(schema.files.hash, cleanHash,),) + .limit(1,) if (!file) { - return c.body(null, 404); + return c.body(null, 404,) } // Check visibility - const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get("accountId")); + const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('accountId',),) if (!accessible) { - return c.body(null, 404); + return c.body(null, 404,) } - c.header("Content-Length", String(file.size)); - c.header("Content-Type", file.mimeType); - return c.body(null, 200); + c.header('Content-Length', String(file.size,),) + c.header('Content-Type', file.mimeType,) + return c.body(null, 200,) } // Download file -export async function getFile(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const hash = c.req.param("hash")!; - const cleanHash = hash.replace("sha256:", ""); +export async function getFile(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const hash = c.req.param('hash',)! + const cleanHash = hash.replace('sha256:', '',) - const [file] = await db + const [file,] = await db .select() - .from(schema.files) - .where(eq(schema.files.hash, cleanHash)) - .limit(1); + .from(schema.files,) + .where(eq(schema.files.hash, cleanHash,),) + .limit(1,) if (!file) { - return c.json({ error: "File not found", statusCode: 404 }, 404); + return c.json({ error: 'File not found', statusCode: 404, }, 404,) } // Check visibility - const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get("accountId")); + const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('accountId',),) if (!accessible) { - return c.json({ error: "File not found", statusCode: 404 }, 404); + return c.json({ error: 'File not found', statusCode: 404, }, 404,) } // Redirect to CDN - const cdnUrl = `https://assets.underlay.org/files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`; - return c.redirect(cdnUrl); + const cdnUrl = `https://assets.underlay.org/files/${cleanHash.slice(0, 2,)}/${cleanHash.slice(2, 4,)}/${cleanHash}` + return c.redirect(cdnUrl,) } // Upload file -export async function putFile(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const hash = c.req.param("hash")!; - const cleanHash = hash.replace("sha256:", ""); - - // Check if file already exists in DB - const [existing] = await db - .select() - .from(schema.files) - .where(eq(schema.files.hash, cleanHash)) - .limit(1); - - if (existing) { - return c.json({ hash: cleanHash, status: "exists" }, 200); - } +export async function putFile(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const hash = c.req.param('hash',)! + const cleanHash = hash.replace('sha256:', '',) + + // Check if file already exists in DB + const [existing,] = await db + .select() + .from(schema.files,) + .where(eq(schema.files.hash, cleanHash,),) + .limit(1,) - // Check if file exists in S3 but not in local DB (shared bucket scenario) - const s3Key = `files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`; - const s3Meta = await getS3ObjectMeta(s3Key); - if (s3Meta !== null) { - await db.insert(schema.files).values({ - hash: cleanHash, - size: s3Meta.size, - mimeType: s3Meta.contentType, - storageKey: s3Key, - }).onConflictDoNothing(); - return c.json({ hash: cleanHash, status: "exists" }, 200); - } + if (existing) { + return c.json({ hash: cleanHash, status: 'exists', }, 200,) + } - // Try multipart first - const contentType = c.req.header("content-type") ?? "application/octet-stream"; - - let buffer: Buffer; - let mimeType: string; - - if (contentType.startsWith("multipart/")) { - const body = await c.req.parseBody(); - const file = body["file"]; - if (file instanceof File) { - const ab = await file.arrayBuffer(); - buffer = Buffer.from(ab); - mimeType = file.type || "application/octet-stream"; - } else { - return c.json({ error: "No file in multipart body", statusCode: 400 }, 400); - } + // Check if file exists in S3 but not in local DB (shared bucket scenario) + const s3Key = `files/${cleanHash.slice(0, 2,)}/${cleanHash.slice(2, 4,)}/${cleanHash}` + const s3Meta = await getS3ObjectMeta(s3Key,) + if (s3Meta !== null) { + await db.insert(schema.files,).values({ + hash: cleanHash, + size: s3Meta.size, + mimeType: s3Meta.contentType, + storageKey: s3Key, + },).onConflictDoNothing() + return c.json({ hash: cleanHash, status: 'exists', }, 200,) + } + + // Try multipart first + const contentType = c.req.header('content-type',) ?? 'application/octet-stream' + + let buffer: Buffer + let mimeType: string + + if (contentType.startsWith('multipart/',)) { + const body = await c.req.parseBody() + const file = body['file'] + if (file instanceof File) { + const ab = await file.arrayBuffer() + buffer = Buffer.from(ab,) + mimeType = file.type || 'application/octet-stream' } else { - // Raw binary body - const ab = await c.req.arrayBuffer(); - buffer = Buffer.from(ab); - mimeType = contentType; + return c.json({ error: 'No file in multipart body', statusCode: 400, }, 400,) } + } else { + // Raw binary body + const ab = await c.req.arrayBuffer() + buffer = Buffer.from(ab,) + mimeType = contentType + } - // Verify hash - const computedHash = createHash("sha256").update(buffer).digest("hex"); - if (computedHash !== cleanHash) { - return c.json({ - error: "Hash mismatch", - expected: cleanHash, - computed: computedHash, - statusCode: 400, - }, 400); - } + // Verify hash + const computedHash = createHash('sha256',).update(buffer,).digest('hex',) + if (computedHash !== cleanHash) { + return c.json({ + error: 'Hash mismatch', + expected: cleanHash, + computed: computedHash, + statusCode: 400, + }, 400,) + } - const storageKey = `files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}`; + const storageKey = `files/${cleanHash.slice(0, 2,)}/${cleanHash.slice(2, 4,)}/${cleanHash}` - await uploadToS3(storageKey, buffer, mimeType); + await uploadToS3(storageKey, buffer, mimeType,) - await db.insert(schema.files).values({ - hash: cleanHash, - size: buffer.length, - mimeType, - storageKey, - }); + await db.insert(schema.files,).values({ + hash: cleanHash, + size: buffer.length, + mimeType, + storageKey, + },) - return c.json({ hash: cleanHash, size: buffer.length }, 201); + return c.json({ hash: cleanHash, size: buffer.length, }, 201,) } diff --git a/src/api/health.ts b/src/api/health.ts index 648041b..24bd286 100644 --- a/src/api/health.ts +++ b/src/api/health.ts @@ -1,5 +1,5 @@ -import type { Context } from 'hono' +import type { Context, } from 'hono' -export async function check(c: Context) { - return c.json({ status: 'ok', timestamp: new Date().toISOString() }) +export async function check(c: Context,) { + return c.json({ status: 'ok', timestamp: new Date().toISOString(), },) } diff --git a/src/api/query.ts b/src/api/query.ts index 3be784e..ddc2c16 100644 --- a/src/api/query.ts +++ b/src/api/query.ts @@ -1,194 +1,205 @@ -import type { Context } from 'hono' -import { eq, and, desc, ilike, or, inArray } from 'drizzle-orm'; -import { db, schema } from '../db/client.server.js'; -import { type AuthEnv } from './auth.server.js'; -import { buildSqliteBuffer, generateAllDDL, generateDDL } from '../lib/sqlite-gen.js'; +import { and, desc, eq, ilike, inArray, or, } from 'drizzle-orm' +import type { Context, } from 'hono' +import { db, schema, } from '../db/client.server.js' +import { buildSqliteBuffer, generateAllDDL, generateDDL, } from '../lib/sqlite-gen.js' +import { type AuthEnv, } from './auth.server.js' // In-memory LRU cache: key = `${collectionId}:${versionNumber}`, value = { buffer, expiresAt } -const sqliteCache = new Map>; expiresAt: number }>(); -const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes -const CACHE_MAX_ENTRIES = 10; +const sqliteCache = new Map< + string, + { + buffer: Buffer + ddl: string + ddlWithSamples: string + sampleRows: Record> + expiresAt: number + } +>() +const CACHE_TTL_MS = 30 * 60 * 1000 // 30 minutes +const CACHE_MAX_ENTRIES = 10 function cleanExpired() { - const now = Date.now(); - for (const [key, entry] of sqliteCache) { - if (entry.expiresAt < now) sqliteCache.delete(key); + const now = Date.now() + for (const [key, entry,] of sqliteCache) { + if (entry.expiresAt < now) sqliteCache.delete(key,) } } function evictIfNeeded() { while (sqliteCache.size >= CACHE_MAX_ENTRIES) { // Evict oldest entry (first key in Map insertion order) - const firstKey = sqliteCache.keys().next().value; - if (firstKey) sqliteCache.delete(firstKey); - else break; + const firstKey = sqliteCache.keys().next().value + if (firstKey) sqliteCache.delete(firstKey,) + else break } } // Run cleanup every 5 minutes -setInterval(cleanExpired, 5 * 60 * 1000); +setInterval(cleanExpired, 5 * 60 * 1000,) -async function getOrBuildSqlite(owner: string, slug: string, versionNumber: number) { +async function getOrBuildSqlite(owner: string, slug: string, versionNumber: number,) { // Resolve collection - const [collection] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId, public: schema.collections.public }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); + const [collection,] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId, public: schema.collections.public, },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) - if (!collection) return null; + if (!collection) return null // Resolve version - const [version] = await db - .select({ id: schema.versions.id, number: schema.versions.number }) - .from(schema.versions) - .where(and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, versionNumber))) - .limit(1); + const [version,] = await db + .select({ id: schema.versions.id, number: schema.versions.number, },) + .from(schema.versions,) + .where(and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, versionNumber,),),) + .limit(1,) - if (!version) return null; + if (!version) return null - const cacheKey = `${collection.id}:${version.number}`; + const cacheKey = `${collection.id}:${version.number}` // Check cache (re-insert to move to end for LRU ordering) - const cached = sqliteCache.get(cacheKey); + const cached = sqliteCache.get(cacheKey,) if (cached && cached.expiresAt > Date.now()) { - sqliteCache.delete(cacheKey); - cached.expiresAt = Date.now() + CACHE_TTL_MS; - sqliteCache.set(cacheKey, cached); - return cached; + sqliteCache.delete(cacheKey,) + cached.expiresAt = Date.now() + CACHE_TTL_MS + sqliteCache.set(cacheKey, cached,) + return cached } // Load schemas for this version const versionSchemas = await db - .select({ slug: schema.versionSchemas.slug, schema: schema.schemas.schema }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where(eq(schema.versionSchemas.versionId, version.id)); + .select({ slug: schema.versionSchemas.slug, schema: schema.schemas.schema, },) + .from(schema.versionSchemas,) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id,),) + .where(eq(schema.versionSchemas.versionId, version.id,),) - const schemasMap: Record = {}; + const schemasMap: Record = {} for (const vs of versionSchemas) { - schemasMap[vs.slug] = vs.schema; + schemasMap[vs.slug] = vs.schema } // Load records const records = await db - .select({ recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data }) - .from(schema.records) - .where(eq(schema.records.versionId, version.id)); + .select({ recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data, },) + .from(schema.records,) + .where(eq(schema.records.versionId, version.id,),) // Build SQLite - const buffer = buildSqliteBuffer(schemasMap, records as any); - const ddl = generateAllDDL(schemasMap); + const buffer = buildSqliteBuffer(schemasMap, records as any,) + const ddl = generateAllDDL(schemasMap,) // Generate sample data (first row per table) for LLM context - const sampleRows: Record> = {}; - for (const [typeName] of Object.entries(schemasMap)) { - const firstRecord = records.find((r) => r.type === typeName); + const sampleRows: Record> = {} + for (const [typeName,] of Object.entries(schemasMap,)) { + const firstRecord = records.find((r,) => r.type === typeName) if (firstRecord && firstRecord.data && typeof firstRecord.data === 'object') { - sampleRows[typeName] = firstRecord.data as Record; + sampleRows[typeName] = firstRecord.data as Record } } // Build DDL with inline sample rows (each sample right after its CREATE TABLE) - const ddlWithSamples = Object.entries(schemasMap) - .map(([name, s]) => { - const tableDdl = generateDDL(name, s); - const sample = sampleRows[name]; + const ddlWithSamples = Object.entries(schemasMap,) + .map(([name, s,],) => { + const tableDdl = generateDDL(name, s,) + const sample = sampleRows[name] if (sample) { - return tableDdl + `\n-- Example row: ${JSON.stringify(sample)}`; + return tableDdl + `\n-- Example row: ${JSON.stringify(sample,)}` } - return tableDdl; - }) - .join('\n\n'); - - const entry = { buffer, ddl, ddlWithSamples, sampleRows, expiresAt: Date.now() + CACHE_TTL_MS }; - evictIfNeeded(); - sqliteCache.set(cacheKey, entry); - return entry; + return tableDdl + },) + .join('\n\n',) + + const entry = { buffer, ddl, ddlWithSamples, sampleRows, expiresAt: Date.now() + CACHE_TTL_MS, } + evictIfNeeded() + sqliteCache.set(cacheKey, entry,) + return entry } // GET /query/sqlite/:owner/:slug/:version — Download SQLite file for a version -export async function sqlite(c: Context) { - const owner = c.req.param('owner')!; - const slug = c.req.param('slug')!; - const version = c.req.param('version')!; - const versionNum = parseInt(version, 10); - if (isNaN(versionNum)) return c.json({ error: 'Invalid version number' }, 400); +export async function sqlite(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const version = c.req.param('version',)! + const versionNum = parseInt(version, 10,) + if (isNaN(versionNum,)) return c.json({ error: 'Invalid version number', }, 400,) - const result = await getOrBuildSqlite(owner, slug, versionNum); - if (!result) return c.json({ error: 'Collection or version not found' }, 404); + const result = await getOrBuildSqlite(owner, slug, versionNum,) + if (!result) return c.json({ error: 'Collection or version not found', }, 404,) - return new Response(new Uint8Array(result.buffer), { + return new Response(new Uint8Array(result.buffer,), { status: 200, headers: { 'Content-Type': 'application/x-sqlite3', 'Content-Disposition': `attachment; filename="${slug}-v${versionNum}.sqlite"`, 'Cache-Control': 'public, max-age=86400', }, - }); + },) } // GET /query/ddl/:owner/:slug/:version — Get DDL (schema only) for a version -export async function ddl(c: Context) { - const owner = c.req.param('owner')!; - const slug = c.req.param('slug')!; - const version = c.req.param('version')!; - const versionNum = parseInt(version, 10); - if (isNaN(versionNum)) return c.json({ error: 'Invalid version number' }, 400); +export async function ddl(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const version = c.req.param('version',)! + const versionNum = parseInt(version, 10,) + if (isNaN(versionNum,)) return c.json({ error: 'Invalid version number', }, 400,) - const result = await getOrBuildSqlite(owner, slug, versionNum); - if (!result) return c.json({ error: 'Collection or version not found' }, 404); + const result = await getOrBuildSqlite(owner, slug, versionNum,) + if (!result) return c.json({ error: 'Collection or version not found', }, 404,) - return c.json({ ddl: result.ddl }); + return c.json({ ddl: result.ddl, },) } // POST /query/generate-sql — LLM-powered SQL generation from natural language -export async function generateSql(c: Context) { - const { collections: collectionRefs, question } = await c.req.json(); +export async function generateSql(c: Context,) { + const { collections: collectionRefs, question, } = await c.req.json() if (!collectionRefs?.length || !question) { - return c.json({ error: 'collections and question are required' }, 400); + return c.json({ error: 'collections and question are required', }, 400,) } - const cfAccountId = process.env.CF_ACCOUNT_ID; - const cfApiToken = process.env.CF_API_TOKEN; + const cfAccountId = process.env.CF_ACCOUNT_ID + const cfApiToken = process.env.CF_API_TOKEN if (!cfAccountId || !cfApiToken) { return c.json({ error: 'LLM not configured', - message: 'Set CF_ACCOUNT_ID and CF_API_TOKEN environment variables to enable natural language queries. You can still write SQL directly.', - }, 503); + message: + 'Set CF_ACCOUNT_ID and CF_API_TOKEN environment variables to enable natural language queries. You can still write SQL directly.', + }, 503,) } // Build DDL with sample rows server-side - let combinedDdl: string; - let totalRecords = 0; + let combinedDdl: string + let totalRecords = 0 if (collectionRefs.length === 1) { - const ref = collectionRefs[0]; - const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version); - if (!result) return c.json({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found` }, 404); - combinedDdl = result.ddlWithSamples; + const ref = collectionRefs[0] + const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version,) + if (!result) return c.json({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found`, }, 404,) + combinedDdl = result.ddlWithSamples // Count records from cache (approximation from the version table already captured) } else { - const parts: string[] = []; + const parts: string[] = [] for (const ref of collectionRefs) { - const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version); - if (!result) return c.json({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found` }, 404); - const prefix = ref.slug.replace(/-/g, '_'); + const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version,) + if (!result) return c.json({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found`, }, 404,) + const prefix = ref.slug.replace(/-/g, '_',) // Prefix table names and add _source column to DDL const ddlPrefixed = result.ddlWithSamples - .replace(/CREATE TABLE "([^"]+)"/g, `CREATE TABLE "${prefix}__$1"`) - .replace(/\);/g, `,\n "_source" TEXT\n);`); - parts.push(`-- Collection: ${ref.owner}/${ref.slug} v${ref.version}\n` + ddlPrefixed); + .replace(/CREATE TABLE "([^"]+)"/g, `CREATE TABLE "${prefix}__$1"`,) + .replace(/\);/g, `,\n "_source" TEXT\n);`,) + parts.push(`-- Collection: ${ref.owner}/${ref.slug} v${ref.version}\n` + ddlPrefixed,) } - combinedDdl = parts.join('\n\n'); + combinedDdl = parts.join('\n\n',) } - const isMultiCollection = collectionRefs.length > 1; + const isMultiCollection = collectionRefs.length > 1 - const systemPrompt = `You are a SQL assistant for SQLite databases. Given a schema and a user's question, produce a single SELECT query that answers it. + const systemPrompt = + `You are a SQL assistant for SQLite databases. Given a schema and a user's question, produce a single SELECT query that answers it. Respond in EXACTLY this format (two sections separated by the marker): @@ -199,19 +210,25 @@ REASONING: Important rules: -- Examine the "Example row" comments in the schema — they show the ACTUAL data format stored in each column.${isMultiCollection ? ` +- Examine the "Example row" comments in the schema — they show the ACTUAL data format stored in each column.${ + isMultiCollection + ? ` - When multiple collections are loaded, consider ALL of them in your answer unless the question specifies otherwise. - Every table has a "_source" column containing the collection identifier (e.g. "account/collection"). For row-level results, include _source as a column. For aggregations, include GROUP_CONCAT(DISTINCT _source) as _source so the user can see which collections contributed to the result. -- When counting across multiple tables, use UNION ALL to combine rows, not JOIN.` : ''} +- When counting across multiple tables, use UNION ALL to combine rows, not JOIN.` + : '' + } - Only use JOIN when the question asks about relationships between tables. -- COUNT(*) counts rows.${isMultiCollection ? ' Use UNION ALL to combine rows from separate tables before counting.' : ''} +- COUNT(*) counts rows.${ + isMultiCollection ? ' Use UNION ALL to combine rows from separate tables before counting.' : '' + } - When tables have a prefix like "collection__TableName", always use that full prefixed name. -- Do NOT include columns that don't exist in the schema.`; +- Do NOT include columns that don't exist in the schema.` - const userPrompt = `Schema:\n${combinedDdl}\n\nQuestion: ${question}`; + const userPrompt = `Schema:\n${combinedDdl}\n\nQuestion: ${question}` // Log the full prompt for debugging - console.info(`[generate-sql] User prompt:\n${userPrompt}`); + console.info(`[generate-sql] User prompt:\n${userPrompt}`,) try { const response = await fetch( @@ -224,97 +241,97 @@ Important rules: }, body: JSON.stringify({ messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, + { role: 'system', content: systemPrompt, }, + { role: 'user', content: userPrompt, }, ], max_tokens: 800, temperature: 0, - }), + },), }, - ); + ) if (!response.ok) { - const text = await response.text(); - console.error(`Cloudflare AI error: ${response.status} ${text}`); - return c.json({ error: 'LLM request failed', rawResponse: text }, 502); + const text = await response.text() + console.error(`Cloudflare AI error: ${response.status} ${text}`,) + return c.json({ error: 'LLM request failed', rawResponse: text, }, 502,) } - const data = (await response.json()) as any; - let raw = data?.result?.response?.trim(); + const data = (await response.json()) as any + let raw = data?.result?.response?.trim() if (!raw) { - return c.json({ error: 'LLM returned empty response', rawResponse: JSON.stringify(data) }, 500); + return c.json({ error: 'LLM returned empty response', rawResponse: JSON.stringify(data,), }, 500,) } // Parse structured response - let sql: string; - let reasoning: string | undefined; + let sql: string + let reasoning: string | undefined - const sqlMarker = raw.indexOf('SQL:'); - const reasoningMarker = raw.indexOf('REASONING:'); + const sqlMarker = raw.indexOf('SQL:',) + const reasoningMarker = raw.indexOf('REASONING:',) if (sqlMarker !== -1 && reasoningMarker !== -1) { - sql = raw.substring(sqlMarker + 4, reasoningMarker).replace(/```sql\n?/g, '').replace(/```/g, '').trim(); - reasoning = raw.substring(reasoningMarker + 10).trim(); + sql = raw.substring(sqlMarker + 4, reasoningMarker,).replace(/```sql\n?/g, '',).replace(/```/g, '',).trim() + reasoning = raw.substring(reasoningMarker + 10,).trim() } else { // Fallback: treat entire response as SQL - sql = raw.replace(/```sql\n?/g, '').replace(/```/g, '').trim(); + sql = raw.replace(/```sql\n?/g, '',).replace(/```/g, '',).trim() } // Basic safety: only allow SELECT statements - const normalized = sql.replace(/--.*$/gm, '').trim().toUpperCase(); - if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH')) { + const normalized = sql.replace(/--.*$/gm, '',).trim().toUpperCase() + if (!normalized.startsWith('SELECT',) && !normalized.startsWith('WITH',)) { return c.json({ error: 'Generated query is not a SELECT statement', sql, reasoning, rawResponse: raw, - }, 400); + }, 400,) } - return c.json({ sql, reasoning }); + return c.json({ sql, reasoning, },) } catch (err: any) { - console.error(`LLM generation error: ${err.message}`); - return c.json({ error: 'Failed to generate SQL' }, 500); + console.error(`LLM generation error: ${err.message}`,) + return c.json({ error: 'Failed to generate SQL', }, 500,) } } // GET /query/collections/search?q=term — Search collections (public + user's private) -export async function searchCollections(c: Context) { - const q = c.req.query('q'); - if (!q || q.trim().length < 2) return c.json([]); +export async function searchCollections(c: Context,) { + const q = c.req.query('q',) + if (!q || q.trim().length < 2) return c.json([],) - const term = `%${q.trim()}%`; - const userId = c.get('accountId'); + const term = `%${q.trim()}%` + const userId = c.get('accountId',) // Build accessible account IDs (user's own + orgs they belong to) - let accessibleAccountIds: string[] = []; + let accessibleAccountIds: string[] = [] if (userId) { const memberships = await db - .select({ orgId: schema.orgMemberships.orgId }) - .from(schema.orgMemberships) - .where(eq(schema.orgMemberships.userId, userId)); - accessibleAccountIds = [userId, ...memberships.map((m) => m.orgId)]; + .select({ orgId: schema.orgMemberships.orgId, },) + .from(schema.orgMemberships,) + .where(eq(schema.orgMemberships.userId, userId,),) + accessibleAccountIds = [userId, ...memberships.map((m,) => m.orgId),] } // Query: public collections OR private collections owned by accessible accounts const searchCondition = or( - ilike(schema.accounts.slug, term), - ilike(schema.collections.slug, term), - ilike(schema.collections.name, term), - ); + ilike(schema.accounts.slug, term,), + ilike(schema.collections.slug, term,), + ilike(schema.collections.name, term,), + ) - let whereCondition; + let whereCondition if (accessibleAccountIds.length > 0) { whereCondition = and( searchCondition, or( - eq(schema.collections.public, true), - inArray(schema.collections.accountId, accessibleAccountIds), + eq(schema.collections.public, true,), + inArray(schema.collections.accountId, accessibleAccountIds,), ), - ); + ) } else { - whereCondition = and(searchCondition, eq(schema.collections.public, true)); + whereCondition = and(searchCondition, eq(schema.collections.public, true,),) } const collections = await db @@ -324,23 +341,27 @@ export async function searchCollections(c: Context) { name: schema.collections.name, description: schema.collections.description, public: schema.collections.public, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) - .where(whereCondition) - .limit(20); + },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId,),) + .where(whereCondition,) + .limit(20,) // Get latest version + record count for each match - const result = []; + const result = [] for (const c2 of collections) { - const [latestVersion] = await db - .select({ number: schema.versions.number, semver: schema.versions.semver, recordCount: schema.versions.recordCount }) - .from(schema.versions) - .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId)) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) - .where(and(eq(schema.accounts.slug, c2.ownerSlug), eq(schema.collections.slug, c2.slug))) - .orderBy(desc(schema.versions.number)) - .limit(1); + const [latestVersion,] = await db + .select({ + number: schema.versions.number, + semver: schema.versions.semver, + recordCount: schema.versions.recordCount, + },) + .from(schema.versions,) + .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId,),) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId,),) + .where(and(eq(schema.accounts.slug, c2.ownerSlug,), eq(schema.collections.slug, c2.slug,),),) + .orderBy(desc(schema.versions.number,),) + .limit(1,) result.push({ ownerSlug: c2.ownerSlug, @@ -351,16 +372,16 @@ export async function searchCollections(c: Context) { latestVersion: latestVersion?.number ?? null, latestSemver: latestVersion?.semver ?? null, recordCount: latestVersion?.recordCount ?? 0, - }); + },) } - return c.json(result); + return c.json(result,) } // GET /query/collections/:owner/:slug/versions — List versions for a collection -export async function collectionVersions(c: Context) { - const owner = c.req.param('owner')!; - const slug = c.req.param('slug')!; +export async function collectionVersions(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! const versions = await db .select({ @@ -369,23 +390,22 @@ export async function collectionVersions(c: Context) { recordCount: schema.versions.recordCount, createdAt: schema.versions.createdAt, message: schema.versions.message, - }) - .from(schema.versions) - .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId)) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) + },) + .from(schema.versions,) + .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId,),) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId,),) .where( and( - eq(schema.accounts.slug, owner), - eq(schema.collections.slug, slug), - eq(schema.collections.public, true), + eq(schema.accounts.slug, owner,), + eq(schema.collections.slug, slug,), + eq(schema.collections.public, true,), ), ) - .orderBy(desc(schema.versions.number)); + .orderBy(desc(schema.versions.number,),) if (versions.length === 0) { - return c.json({ error: 'Collection not found or not public' }, 404); + return c.json({ error: 'Collection not found or not public', }, 404,) } - return c.json(versions); + return c.json(versions,) } - diff --git a/src/api/schemas.ts b/src/api/schemas.ts index a916ebf..47af20b 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -1,78 +1,78 @@ -import type { Context } from 'hono' -import { eq, and, sql, inArray, ilike } from 'drizzle-orm'; -import { db, schema } from '../db/client.server.js'; -import { type AuthEnv } from './auth.server.js'; +import { and, eq, ilike, inArray, sql, } from 'drizzle-orm' +import type { Context, } from 'hono' +import { db, schema, } from '../db/client.server.js' +import { type AuthEnv, } from './auth.server.js' // --- Global schema search --- // GET /schemas?q=...&slug=...&label=...&schema_hash=...&limit=...&offset=... -export async function listSchemas(c: Context) { - const q = c.req.query('q'); - const slugFilter = c.req.query('slug'); - const label = c.req.query('label'); - const schema_hash = c.req.query('schema_hash'); - const limit = c.req.query('limit'); - const offset = c.req.query('offset'); +export async function listSchemas(c: Context,) { + const q = c.req.query('q',) + const slugFilter = c.req.query('slug',) + const label = c.req.query('label',) + const schema_hash = c.req.query('schema_hash',) + const limit = c.req.query('limit',) + const offset = c.req.query('offset',) - const pageLimit = Math.min(parseInt(limit ?? '50', 10), 100); - const pageOffset = parseInt(offset ?? '0', 10); + const pageLimit = Math.min(parseInt(limit ?? '50', 10,), 100,) + const pageOffset = parseInt(offset ?? '0', 10,) // Search by exact hash if (schema_hash) { - const [row] = await db + const [row,] = await db .select() - .from(schema.schemas) - .where(eq(schema.schemas.schemaHash, schema_hash)) - .limit(1); + .from(schema.schemas,) + .where(eq(schema.schemas.schemaHash, schema_hash,),) + .limit(1,) - if (!row) return c.json({ error: 'Schema not found', statusCode: 404 }, 404); + if (!row) return c.json({ error: 'Schema not found', statusCode: 404, }, 404,) const labels = await db - .select({ label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(eq(schema.schemaLabels.schemaId, row.id)); + .select({ label: schema.schemaLabels.label, },) + .from(schema.schemaLabels,) + .where(eq(schema.schemaLabels.schemaId, row.id,),) - const usageCount = await getUsageCount(row.id); + const usageCount = await getUsageCount(row.id,) return c.json({ ...row, - labels: labels.map((l) => l.label), + labels: labels.map((l,) => l.label), usageCount, - }); + },) } // Search by slug (find schemas used as a particular type name) if (slugFilter) { const vsRows = await db - .select({ schemaId: schema.versionSchemas.schemaId }) - .from(schema.versionSchemas) - .where(eq(schema.versionSchemas.slug, slugFilter)) - .groupBy(schema.versionSchemas.schemaId) - .limit(pageLimit) - .offset(pageOffset); + .select({ schemaId: schema.versionSchemas.schemaId, },) + .from(schema.versionSchemas,) + .where(eq(schema.versionSchemas.slug, slugFilter,),) + .groupBy(schema.versionSchemas.schemaId,) + .limit(pageLimit,) + .offset(pageOffset,) - if (vsRows.length === 0) return c.json([]); + if (vsRows.length === 0) return c.json([],) - const schemaIds = vsRows.map((r) => r.schemaId); + const schemaIds = vsRows.map((r,) => r.schemaId) const schemaRows = await db .select() - .from(schema.schemas) - .where(inArray(schema.schemas.id, schemaIds)); + .from(schema.schemas,) + .where(inArray(schema.schemas.id, schemaIds,),) const allLabels = await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(inArray(schema.schemaLabels.schemaId, schemaIds)); + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, },) + .from(schema.schemaLabels,) + .where(inArray(schema.schemaLabels.schemaId, schemaIds,),) - const labelsMap = new Map(); + const labelsMap = new Map() for (const l of allLabels) { - if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); - labelsMap.get(l.schemaId)!.push(l.label); + if (!labelsMap.has(l.schemaId,)) labelsMap.set(l.schemaId, [],) + labelsMap.get(l.schemaId,)!.push(l.label,) } - return c.json(schemaRows.map((s) => ({ + return c.json(schemaRows.map((s,) => ({ ...s, - labels: labelsMap.get(s.id) ?? [], - }))); + labels: labelsMap.get(s.id,) ?? [], + })),) } // Search by label @@ -81,112 +81,112 @@ export async function listSchemas(c: Context) { .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, - }) - .from(schema.schemaLabels) - .where(ilike(schema.schemaLabels.label, `%${label}%`)) - .limit(pageLimit) - .offset(pageOffset); + },) + .from(schema.schemaLabels,) + .where(ilike(schema.schemaLabels.label, `%${label}%`,),) + .limit(pageLimit,) + .offset(pageOffset,) - if (labelRows.length === 0) return c.json([]); + if (labelRows.length === 0) return c.json([],) - const schemaIds = [...new Set(labelRows.map((r) => r.schemaId))]; + const schemaIds = [...new Set(labelRows.map((r,) => r.schemaId),),] const schemaRows = await db .select() - .from(schema.schemas) - .where(inArray(schema.schemas.id, schemaIds)); + .from(schema.schemas,) + .where(inArray(schema.schemas.id, schemaIds,),) // Gather all labels for these schemas const allLabels = await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(inArray(schema.schemaLabels.schemaId, schemaIds)); + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, },) + .from(schema.schemaLabels,) + .where(inArray(schema.schemaLabels.schemaId, schemaIds,),) - const labelsMap = new Map(); + const labelsMap = new Map() for (const l of allLabels) { - if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); - labelsMap.get(l.schemaId)!.push(l.label); + if (!labelsMap.has(l.schemaId,)) labelsMap.set(l.schemaId, [],) + labelsMap.get(l.schemaId,)!.push(l.label,) } - return c.json(schemaRows.map((s) => ({ + return c.json(schemaRows.map((s,) => ({ ...s, - labels: labelsMap.get(s.id) ?? [], - }))); + labels: labelsMap.get(s.id,) ?? [], + })),) } // Full-text search across schema JSON (search for field names, types, etc.) if (q) { const rows = await db .select() - .from(schema.schemas) - .where(sql`${schema.schemas.schema}::text ILIKE ${'%' + q + '%'}`) - .limit(pageLimit) - .offset(pageOffset); + .from(schema.schemas,) + .where(sql`${schema.schemas.schema}::text ILIKE ${'%' + q + '%'}`,) + .limit(pageLimit,) + .offset(pageOffset,) - const schemaIds = rows.map((r) => r.id); + const schemaIds = rows.map((r,) => r.id) const allLabels = schemaIds.length > 0 ? await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(inArray(schema.schemaLabels.schemaId, schemaIds)) - : []; + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, },) + .from(schema.schemaLabels,) + .where(inArray(schema.schemaLabels.schemaId, schemaIds,),) + : [] - const labelsMap = new Map(); + const labelsMap = new Map() for (const l of allLabels) { - if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); - labelsMap.get(l.schemaId)!.push(l.label); + if (!labelsMap.has(l.schemaId,)) labelsMap.set(l.schemaId, [],) + labelsMap.get(l.schemaId,)!.push(l.label,) } - return c.json(rows.map((s) => ({ + return c.json(rows.map((s,) => ({ ...s, - labels: labelsMap.get(s.id) ?? [], - }))); + labels: labelsMap.get(s.id,) ?? [], + })),) } // No filter: list all schemas const rows = await db .select() - .from(schema.schemas) - .orderBy(sql`${schema.schemas.createdAt} desc`) - .limit(pageLimit) - .offset(pageOffset); + .from(schema.schemas,) + .orderBy(sql`${schema.schemas.createdAt} desc`,) + .limit(pageLimit,) + .offset(pageOffset,) - const schemaIds = rows.map((r) => r.id); + const schemaIds = rows.map((r,) => r.id) const allLabels = schemaIds.length > 0 ? await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(inArray(schema.schemaLabels.schemaId, schemaIds)) - : []; + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, },) + .from(schema.schemaLabels,) + .where(inArray(schema.schemaLabels.schemaId, schemaIds,),) + : [] - const labelsMap = new Map(); + const labelsMap = new Map() for (const l of allLabels) { - if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); - labelsMap.get(l.schemaId)!.push(l.label); + if (!labelsMap.has(l.schemaId,)) labelsMap.set(l.schemaId, [],) + labelsMap.get(l.schemaId,)!.push(l.label,) } - return c.json(rows.map((s) => ({ + return c.json(rows.map((s,) => ({ ...s, - labels: labelsMap.get(s.id) ?? [], - }))); + labels: labelsMap.get(s.id,) ?? [], + })),) } // --- Single schema by ID --- // GET /schemas/:id -export async function getSchema(c: Context) { - const id = c.req.param('id')!; +export async function getSchema(c: Context,) { + const id = c.req.param('id',)! - const [row] = await db + const [row,] = await db .select() - .from(schema.schemas) - .where(eq(schema.schemas.id, id)) - .limit(1); + .from(schema.schemas,) + .where(eq(schema.schemas.id, id,),) + .limit(1,) - if (!row) return c.json({ error: 'Schema not found', statusCode: 404 }, 404); + if (!row) return c.json({ error: 'Schema not found', statusCode: 404, }, 404,) const labels = await db - .select({ label: schema.schemaLabels.label, createdAt: schema.schemaLabels.createdAt }) - .from(schema.schemaLabels) - .where(eq(schema.schemaLabels.schemaId, id)); + .select({ label: schema.schemaLabels.label, createdAt: schema.schemaLabels.createdAt, },) + .from(schema.schemaLabels,) + .where(eq(schema.schemaLabels.schemaId, id,),) // Usage: which collections/versions reference this schema const usage = await db @@ -197,68 +197,68 @@ export async function getSchema(c: Context) { collectionSlug: schema.collections.slug, owner: schema.accounts.slug, isPublic: schema.collections.public, - }) - .from(schema.versionSchemas) - .innerJoin(schema.versions, eq(schema.versionSchemas.versionId, schema.versions.id)) - .innerJoin(schema.collections, eq(schema.versions.collectionId, schema.collections.id)) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.versionSchemas.schemaId, id), eq(schema.collections.public, true))) - .orderBy(sql`${schema.versions.createdAt} desc`) - .limit(50); + },) + .from(schema.versionSchemas,) + .innerJoin(schema.versions, eq(schema.versionSchemas.versionId, schema.versions.id,),) + .innerJoin(schema.collections, eq(schema.versions.collectionId, schema.collections.id,),) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.versionSchemas.schemaId, id,), eq(schema.collections.public, true,),),) + .orderBy(sql`${schema.versions.createdAt} desc`,) + .limit(50,) return c.json({ ...row, - labels: labels.map((l) => ({ label: l.label, createdAt: l.createdAt })), - usage: usage.map((u) => ({ + labels: labels.map((l,) => ({ label: l.label, createdAt: l.createdAt, })), + usage: usage.map((u,) => ({ slug: u.slug, semver: u.semver, versionNumber: u.versionNumber, collection: `${u.owner}/${u.collectionSlug}`, })), - }); + },) } // --- Collection schemas (for a specific version or latest) --- // GET /collections/:owner/:slug/schemas?version=N -export async function collectionSchemas(c: Context) { - const owner = c.req.param('owner')!; - const slug = c.req.param('slug')!; - const versionParam = c.req.query('version'); - const raw = c.req.query('raw'); +export async function collectionSchemas(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const versionParam = c.req.query('version',) + const raw = c.req.query('raw',) // Resolve collection - const [collection] = await db + const [collection,] = await db .select({ id: schema.collections.id, accountId: schema.collections.accountId, public: schema.collections.public, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); + },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404); + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) // Visibility check - if (!collection.public && c.get('accountId') !== collection.accountId) { - return c.json({ error: 'Collection not found', statusCode: 404 }, 404); + if (!collection.public && c.get('accountId',) !== collection.accountId) { + return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) } // Resolve version - const versionConditions = [eq(schema.versions.collectionId, collection.id)]; + const versionConditions = [eq(schema.versions.collectionId, collection.id,),] if (versionParam) { - versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10))); + versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10,),),) } - const [version] = await db - .select({ id: schema.versions.id, number: schema.versions.number, semver: schema.versions.semver }) - .from(schema.versions) - .where(and(...versionConditions)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); + const [version,] = await db + .select({ id: schema.versions.id, number: schema.versions.number, semver: schema.versions.semver, },) + .from(schema.versions,) + .where(and(...versionConditions,),) + .orderBy(sql`${schema.versions.number} desc`,) + .limit(1,) - if (!version) return c.json({ error: 'No versions found', statusCode: 404 }, 404); + if (!version) return c.json({ error: 'No versions found', statusCode: 404, }, 404,) // Load schemas for this version const entries = await db @@ -267,113 +267,112 @@ export async function collectionSchemas(c: Context) { schemaId: schema.versionSchemas.schemaId, schemaBody: schema.schemas.schema, schemaHash: schema.schemas.schemaHash, - }) - .from(schema.versionSchemas) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) - .where(eq(schema.versionSchemas.versionId, version.id)); + },) + .from(schema.versionSchemas,) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id,),) + .where(eq(schema.versionSchemas.versionId, version.id,),) // Load labels for all referenced schemas (unless raw mode) - let labelsMap = new Map(); + let labelsMap = new Map() if (raw !== 'true' && entries.length > 0) { - const schemaIds = entries.map((e) => e.schemaId); + const schemaIds = entries.map((e,) => e.schemaId) const allLabels = await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) - .from(schema.schemaLabels) - .where(inArray(schema.schemaLabels.schemaId, schemaIds)); + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, },) + .from(schema.schemaLabels,) + .where(inArray(schema.schemaLabels.schemaId, schemaIds,),) for (const l of allLabels) { - if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []); - labelsMap.get(l.schemaId)!.push(l.label); + if (!labelsMap.has(l.schemaId,)) labelsMap.set(l.schemaId, [],) + labelsMap.get(l.schemaId,)!.push(l.label,) } } return c.json({ version: version.number, semver: version.semver, - schemas: entries.map((e) => { - const labels = labelsMap.get(e.schemaId) ?? []; + schemas: entries.map((e,) => { + const labels = labelsMap.get(e.schemaId,) ?? [] const body = raw === 'true' ? e.schemaBody : labels.length > 0 - ? { ...(e.schemaBody as object), 'x-underlay-labels': labels } - : e.schemaBody; + ? { ...(e.schemaBody as object), 'x-underlay-labels': labels, } + : e.schemaBody return { slug: e.slug, schemaId: e.schemaId, schemaHash: e.schemaHash, schema: body, - }; - }), - }); + } + },), + },) } // --- Label management --- // Add a label to a schema // POST /schemas/:id/labels { label: "schema.org/Person" } -export async function addLabel(c: Context) { - const id = c.req.param('id')!; - const { label } = await c.req.json(); +export async function addLabel(c: Context,) { + const id = c.req.param('id',)! + const { label, } = await c.req.json() if (!label || typeof label !== 'string' || label.trim().length === 0) { - return c.json({ error: 'Label is required', statusCode: 400 }, 400); + return c.json({ error: 'Label is required', statusCode: 400, }, 400,) } // Verify schema exists - const [existing] = await db - .select({ id: schema.schemas.id }) - .from(schema.schemas) - .where(eq(schema.schemas.id, id)) - .limit(1); + const [existing,] = await db + .select({ id: schema.schemas.id, },) + .from(schema.schemas,) + .where(eq(schema.schemas.id, id,),) + .limit(1,) if (!existing) { - return c.json({ error: 'Schema not found', statusCode: 404 }, 404); + return c.json({ error: 'Schema not found', statusCode: 404, }, 404,) } // Upsert label (ignore conflict on duplicate) try { - const [inserted] = await db - .insert(schema.schemaLabels) - .values({ schemaId: id, label: label.trim() }) + const [inserted,] = await db + .insert(schema.schemaLabels,) + .values({ schemaId: id, label: label.trim(), },) .onConflictDoNothing() - .returning(); + .returning() if (!inserted) { - return c.json({ status: 'exists', schemaId: id, label: label.trim() }); + return c.json({ status: 'exists', schemaId: id, label: label.trim(), },) } - return c.json({ status: 'created', schemaId: id, label: label.trim() }, 201); + return c.json({ status: 'created', schemaId: id, label: label.trim(), }, 201,) } catch (err: any) { - return c.json({ error: 'Failed to add label', statusCode: 500 }, 500); + return c.json({ error: 'Failed to add label', statusCode: 500, }, 500,) } } // Remove a label from a schema // DELETE /schemas/:id/labels/:label -export async function removeLabel(c: Context) { - const id = c.req.param('id')!; - const label = c.req.param('label')!; +export async function removeLabel(c: Context,) { + const id = c.req.param('id',)! + const label = c.req.param('label',)! const result = await db - .delete(schema.schemaLabels) - .where(and(eq(schema.schemaLabels.schemaId, id), eq(schema.schemaLabels.label, label))) - .returning(); + .delete(schema.schemaLabels,) + .where(and(eq(schema.schemaLabels.schemaId, id,), eq(schema.schemaLabels.label, label,),),) + .returning() if (result.length === 0) { - return c.json({ error: 'Label not found', statusCode: 404 }, 404); + return c.json({ error: 'Label not found', statusCode: 404, }, 404,) } - return c.json({ status: 'deleted', schemaId: id, label }); + return c.json({ status: 'deleted', schemaId: id, label, },) } // --- Helpers --- -async function getUsageCount(schemaId: string): Promise { - const [result] = await db - .select({ count: sql`count(distinct ${schema.versionSchemas.versionId})::int` }) - .from(schema.versionSchemas) - .where(eq(schema.versionSchemas.schemaId, schemaId)); - return result?.count ?? 0; +async function getUsageCount(schemaId: string,): Promise { + const [result,] = await db + .select({ count: sql`count(distinct ${schema.versionSchemas.versionId})::int`, },) + .from(schema.versionSchemas,) + .where(eq(schema.versionSchemas.schemaId, schemaId,),) + return result?.count ?? 0 } - diff --git a/src/api/uploads.ts b/src/api/uploads.ts index e26da44..9f50200 100644 --- a/src/api/uploads.ts +++ b/src/api/uploads.ts @@ -1,441 +1,441 @@ -import type { Context } from 'hono' -import { eq, and, sql, inArray } from "drizzle-orm"; -import { db, schema } from "../db/client.server.js"; -import { type AuthEnv } from "./auth.server.js"; -import { createHash } from "node:crypto"; -import { getS3ObjectMeta } from "../lib/s3.js"; +import { and, eq, inArray, sql, } from 'drizzle-orm' +import type { Context, } from 'hono' +import { createHash, } from 'node:crypto' +import { db, schema, } from '../db/client.server.js' +import { getS3ObjectMeta, } from '../lib/s3.js' import { ajv, - type SchemaEntry, - loadVersionSchemas, - getPrivateTypes, - getPrivateFields, + deriveSemver, filterRecordData, filterTypeSchema, + getPrivateFields, + getPrivateTypes, hashSchema, - deriveSemver, + loadVersionSchemas, + type SchemaEntry, } from '../lib/version-helpers.server.js' +import { type AuthEnv, } from './auth.server.js' /** Session expiry: 1 hour from creation */ -const SESSION_TTL_MS = 60 * 60 * 1000; +const SESSION_TTL_MS = 60 * 60 * 1000 /** Max records per batch request */ -const MAX_BATCH_SIZE = 10_000; +const MAX_BATCH_SIZE = 10_000 -async function resolveCollection(owner: string, slug: string) { - const [result] = await db +async function resolveCollection(owner: string, slug: string,) { + const [result,] = await db .select({ id: schema.collections.id, accountId: schema.collections.accountId, slug: schema.collections.slug, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - return result ?? null; + },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) + return result ?? null } // --- Start a chunked upload session --- -export async function startSession(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const body = await c.req.json<{ - base_version: number | null; - message?: string; - readme?: string; - app_id?: string; - actor_id?: string; - schemas?: Record; - }>(); - - const collection = await resolveCollection(owner, slug); - if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); - - // Verify the caller owns this collection - if (c.get("accountId") !== collection.accountId) { - return c.json({ error: "Not authorized for this collection", statusCode: 403 }, 403); - } - - // Optimistic lock check at session creation time - const [latest] = await db - .select({ number: schema.versions.number }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); - - const currentNumber = latest?.number ?? 0; - if (body.base_version !== null && body.base_version !== currentNumber) { - return c.json({ - error: "Version conflict", - currentVersion: currentNumber, - statusCode: 409, - }, 409); - } - - const expiresAt = new Date(Date.now() + SESSION_TTL_MS); - - const [session] = await db - .insert(schema.uploadSessions) - .values({ - collectionId: collection.id, - accountId: c.get("accountId")!, - baseVersion: body.base_version ?? null, - message: body.message ?? null, - readme: body.readme ?? null, - appId: body.app_id ?? null, - actorId: body.actor_id ?? null, - schemas: body.schemas ? (body.schemas as any) : null, - status: "open", - recordCount: 0, - expiresAt, - }) - .returning({ id: schema.uploadSessions.id }); - +export async function startSession(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const body = await c.req.json<{ + base_version: number | null + message?: string + readme?: string + app_id?: string + actor_id?: string + schemas?: Record + }>() + + const collection = await resolveCollection(owner, slug,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + + // Verify the caller owns this collection + if (c.get('accountId',) !== collection.accountId) { + return c.json({ error: 'Not authorized for this collection', statusCode: 403, }, 403,) + } + + // Optimistic lock check at session creation time + const [latest,] = await db + .select({ number: schema.versions.number, },) + .from(schema.versions,) + .where(eq(schema.versions.collectionId, collection.id,),) + .orderBy(sql`${schema.versions.number} desc`,) + .limit(1,) + + const currentNumber = latest?.number ?? 0 + if (body.base_version !== null && body.base_version !== currentNumber) { return c.json({ - sessionId: session!.id, - expiresAt: expiresAt.toISOString(), - }, 201); + error: 'Version conflict', + currentVersion: currentNumber, + statusCode: 409, + }, 409,) + } + + const expiresAt = new Date(Date.now() + SESSION_TTL_MS,) + + const [session,] = await db + .insert(schema.uploadSessions,) + .values({ + collectionId: collection.id, + accountId: c.get('accountId',)!, + baseVersion: body.base_version ?? null, + message: body.message ?? null, + readme: body.readme ?? null, + appId: body.app_id ?? null, + actorId: body.actor_id ?? null, + schemas: body.schemas ? (body.schemas as any) : null, + status: 'open', + recordCount: 0, + expiresAt, + },) + .returning({ id: schema.uploadSessions.id, },) + + return c.json({ + sessionId: session!.id, + expiresAt: expiresAt.toISOString(), + }, 201,) } // --- Append a batch of changes to a session --- -export async function appendBatch(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const sessionId = c.req.param("sessionId")!; - const body = await c.req.json<{ - changes: { - added?: { id: string; type: string; data: unknown; private?: boolean }[]; - updated?: { id: string; type: string; data: unknown; private?: boolean }[]; - removed?: string[]; - }; - }>(); - - // Validate session exists and belongs to caller - const [session] = await db - .select() - .from(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)) - .limit(1); - - if (!session) { - return c.json({ error: "Upload session not found", statusCode: 404 }, 404); - } - if (session.accountId !== c.get("accountId")) { - return c.json({ error: "Not authorized for this session", statusCode: 403 }, 403); - } - if (session.status !== "open") { - return c.json({ - error: "Session is not open", - status: session.status, - statusCode: 409, - }, 409); - } - if (new Date(session.expiresAt) < new Date()) { - await db - .update(schema.uploadSessions) - .set({ status: "expired" }) - .where(eq(schema.uploadSessions.id, sessionId)); - return c.json({ error: "Upload session expired", statusCode: 410 }, 410); - } - - // Verify collection matches - const collection = await resolveCollection(owner, slug); - if (!collection || collection.id !== session.collectionId) { - return c.json({ error: "Collection mismatch", statusCode: 404 }, 404); - } - - // Count total records in this batch - const addedCount = body.changes.added?.length ?? 0; - const updatedCount = body.changes.updated?.length ?? 0; - const removedCount = body.changes.removed?.length ?? 0; - const batchSize = addedCount + updatedCount + removedCount; - - if (batchSize === 0) { - return c.json({ error: "Empty batch", statusCode: 400 }, 400); - } - if (batchSize > MAX_BATCH_SIZE) { - return c.json({ - error: `Batch too large. Maximum ${MAX_BATCH_SIZE} records per batch.`, - statusCode: 400, - }, 400); - } - - // Insert records into staging table (upsert to handle re-sends) - const rows: { - sessionId: string; - recordId: string; - type: string | null; - data: any; - private: boolean; - operation: "add" | "update" | "remove"; - }[] = []; - - for (const rec of body.changes.added ?? []) { - rows.push({ - sessionId, - recordId: rec.id, - type: rec.type, - data: rec.data, - private: rec.private ?? false, - operation: "add", - }); +export async function appendBatch(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const sessionId = c.req.param('sessionId',)! + const body = await c.req.json<{ + changes: { + added?: { id: string; type: string; data: unknown; private?: boolean }[] + updated?: { id: string; type: string; data: unknown; private?: boolean }[] + removed?: string[] } - for (const rec of body.changes.updated ?? []) { - rows.push({ - sessionId, - recordId: rec.id, - type: rec.type, - data: rec.data, - private: rec.private ?? false, - operation: "update", - }); - } - for (const id of body.changes.removed ?? []) { - rows.push({ - sessionId, - recordId: id, - type: null, - data: null, - private: false, - operation: "remove", - }); - } - - // Batch insert (upsert: last write wins for same recordId) - const BATCH = 1000; - for (let i = 0; i < rows.length; i += BATCH) { - const batch = rows.slice(i, i + BATCH); - await db - .insert(schema.uploadRecords) - .values(batch) - .onConflictDoUpdate({ - target: [schema.uploadRecords.sessionId, schema.uploadRecords.recordId], - set: { - type: sql`excluded.type`, - data: sql`excluded.data`, - private: sql`excluded.private`, - operation: sql`excluded.operation`, - }, - }); - } - - // Update session record count - const [countResult] = await db - .select({ count: sql`count(*)` }) - .from(schema.uploadRecords) - .where(eq(schema.uploadRecords.sessionId, sessionId)); - + }>() + + // Validate session exists and belongs to caller + const [session,] = await db + .select() + .from(schema.uploadSessions,) + .where(eq(schema.uploadSessions.id, sessionId,),) + .limit(1,) + + if (!session) { + return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + } + if (session.accountId !== c.get('accountId',)) { + return c.json({ error: 'Not authorized for this session', statusCode: 403, }, 403,) + } + if (session.status !== 'open') { + return c.json({ + error: 'Session is not open', + status: session.status, + statusCode: 409, + }, 409,) + } + if (new Date(session.expiresAt,) < new Date()) { await db - .update(schema.uploadSessions) - .set({ recordCount: countResult?.count ?? 0 }) - .where(eq(schema.uploadSessions.id, sessionId)); - + .update(schema.uploadSessions,) + .set({ status: 'expired', },) + .where(eq(schema.uploadSessions.id, sessionId,),) + return c.json({ error: 'Upload session expired', statusCode: 410, }, 410,) + } + + // Verify collection matches + const collection = await resolveCollection(owner, slug,) + if (!collection || collection.id !== session.collectionId) { + return c.json({ error: 'Collection mismatch', statusCode: 404, }, 404,) + } + + // Count total records in this batch + const addedCount = body.changes.added?.length ?? 0 + const updatedCount = body.changes.updated?.length ?? 0 + const removedCount = body.changes.removed?.length ?? 0 + const batchSize = addedCount + updatedCount + removedCount + + if (batchSize === 0) { + return c.json({ error: 'Empty batch', statusCode: 400, }, 400,) + } + if (batchSize > MAX_BATCH_SIZE) { return c.json({ - received: { added: addedCount, updated: updatedCount, removed: removedCount }, - totalStaged: countResult?.count ?? 0, - }); + error: `Batch too large. Maximum ${MAX_BATCH_SIZE} records per batch.`, + statusCode: 400, + }, 400,) + } + + // Insert records into staging table (upsert to handle re-sends) + const rows: { + sessionId: string + recordId: string + type: string | null + data: any + private: boolean + operation: 'add' | 'update' | 'remove' + }[] = [] + + for (const rec of body.changes.added ?? []) { + rows.push({ + sessionId, + recordId: rec.id, + type: rec.type, + data: rec.data, + private: rec.private ?? false, + operation: 'add', + },) + } + for (const rec of body.changes.updated ?? []) { + rows.push({ + sessionId, + recordId: rec.id, + type: rec.type, + data: rec.data, + private: rec.private ?? false, + operation: 'update', + },) + } + for (const id of body.changes.removed ?? []) { + rows.push({ + sessionId, + recordId: id, + type: null, + data: null, + private: false, + operation: 'remove', + },) + } + + // Batch insert (upsert: last write wins for same recordId) + const BATCH = 1000 + for (let i = 0; i < rows.length; i += BATCH) { + const batch = rows.slice(i, i + BATCH,) + await db + .insert(schema.uploadRecords,) + .values(batch,) + .onConflictDoUpdate({ + target: [schema.uploadRecords.sessionId, schema.uploadRecords.recordId,], + set: { + type: sql`excluded.type`, + data: sql`excluded.data`, + private: sql`excluded.private`, + operation: sql`excluded.operation`, + }, + },) + } + + // Update session record count + const [countResult,] = await db + .select({ count: sql`count(*)`, },) + .from(schema.uploadRecords,) + .where(eq(schema.uploadRecords.sessionId, sessionId,),) + + await db + .update(schema.uploadSessions,) + .set({ recordCount: countResult?.count ?? 0, },) + .where(eq(schema.uploadSessions.id, sessionId,),) + + return c.json({ + received: { added: addedCount, updated: updatedCount, removed: removedCount, }, + totalStaged: countResult?.count ?? 0, + },) } // --- Get session status --- -export async function getSession(c: Context) { - const sessionId = c.req.param("sessionId")!; - - const [session] = await db - .select() - .from(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)) - .limit(1); - - if (!session) { - return c.json({ error: "Upload session not found", statusCode: 404 }, 404); - } - if (session.accountId !== c.get("accountId")) { - return c.json({ error: "Not authorized for this session", statusCode: 403 }, 403); - } - - return c.json({ - sessionId: session.id, - status: session.status, - recordCount: session.recordCount, - baseVersion: session.baseVersion, - expiresAt: session.expiresAt, - createdAt: session.createdAt, - }); +export async function getSession(c: Context,) { + const sessionId = c.req.param('sessionId',)! + + const [session,] = await db + .select() + .from(schema.uploadSessions,) + .where(eq(schema.uploadSessions.id, sessionId,),) + .limit(1,) + + if (!session) { + return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + } + if (session.accountId !== c.get('accountId',)) { + return c.json({ error: 'Not authorized for this session', statusCode: 403, }, 403,) + } + + return c.json({ + sessionId: session.id, + status: session.status, + recordCount: session.recordCount, + baseVersion: session.baseVersion, + expiresAt: session.expiresAt, + createdAt: session.createdAt, + },) } // --- Finalize: build the version from staged records --- -export async function finalize(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const sessionId = c.req.param("sessionId")!; - - // Load and validate session - const [session] = await db +export async function finalize(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const sessionId = c.req.param('sessionId',)! + + // Load and validate session + const [session,] = await db + .select() + .from(schema.uploadSessions,) + .where(eq(schema.uploadSessions.id, sessionId,),) + .limit(1,) + + if (!session) { + return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + } + if (session.accountId !== c.get('accountId',)) { + return c.json({ error: 'Not authorized for this session', statusCode: 403, }, 403,) + } + if (session.status !== 'open') { + return c.json({ + error: `Session cannot be finalized (status: ${session.status})`, + statusCode: 409, + }, 409,) + } + if (new Date(session.expiresAt,) < new Date()) { + await db + .update(schema.uploadSessions,) + .set({ status: 'expired', },) + .where(eq(schema.uploadSessions.id, sessionId,),) + return c.json({ error: 'Upload session expired', statusCode: 410, }, 410,) + } + + const collection = await resolveCollection(owner, slug,) + if (!collection || collection.id !== session.collectionId) { + return c.json({ error: 'Collection mismatch', statusCode: 404, }, 404,) + } + + // Mark session as finalizing + await db + .update(schema.uploadSessions,) + .set({ status: 'finalizing', },) + .where(eq(schema.uploadSessions.id, sessionId,),) + + try { + // Re-check optimistic lock + const [latest,] = await db .select() - .from(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)) - .limit(1); + .from(schema.versions,) + .where(eq(schema.versions.collectionId, collection.id,),) + .orderBy(sql`${schema.versions.number} desc`,) + .limit(1,) - if (!session) { - return c.json({ error: "Upload session not found", statusCode: 404 }, 404); - } - if (session.accountId !== c.get("accountId")) { - return c.json({ error: "Not authorized for this session", statusCode: 403 }, 403); - } - if (session.status !== "open") { + const currentNumber = latest?.number ?? 0 + if (session.baseVersion !== null && session.baseVersion !== currentNumber) { + await db + .update(schema.uploadSessions,) + .set({ status: 'failed', },) + .where(eq(schema.uploadSessions.id, sessionId,),) return c.json({ - error: `Session cannot be finalized (status: ${session.status})`, + error: 'Version conflict', + currentVersion: currentNumber, statusCode: 409, - }, 409); - } - if (new Date(session.expiresAt) < new Date()) { - await db - .update(schema.uploadSessions) - .set({ status: "expired" }) - .where(eq(schema.uploadSessions.id, sessionId)); - return c.json({ error: "Upload session expired", statusCode: 410 }, 410); + }, 409,) } - const collection = await resolveCollection(owner, slug); - if (!collection || collection.id !== session.collectionId) { - return c.json({ error: "Collection mismatch", statusCode: 404 }, 404); + // --- Resolve schemas --- + let prevSchemaEntries: SchemaEntry[] = [] + if (latest) { + prevSchemaEntries = await loadVersionSchemas(latest.id,) } - // Mark session as finalizing - await db - .update(schema.uploadSessions) - .set({ status: "finalizing" }) - .where(eq(schema.uploadSessions.id, sessionId)); - - try { - // Re-check optimistic lock - const [latest] = await db - .select() - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); - - const currentNumber = latest?.number ?? 0; - if (session.baseVersion !== null && session.baseVersion !== currentNumber) { - await db - .update(schema.uploadSessions) - .set({ status: "failed" }) - .where(eq(schema.uploadSessions.id, sessionId)); - return c.json({ - error: "Version conflict", - currentVersion: currentNumber, - statusCode: 409, - }, 409); - } - - // --- Resolve schemas --- - let prevSchemaEntries: SchemaEntry[] = []; - if (latest) { - prevSchemaEntries = await loadVersionSchemas(latest.id); - } + let schemasInput: Record + if (session.schemas && Object.keys(session.schemas as object,).length > 0) { + schemasInput = session.schemas as Record + } else if (prevSchemaEntries.length > 0) { + schemasInput = Object.fromEntries(prevSchemaEntries.map((e,) => [e.slug, e.schema,]),) + } else { + await db + .update(schema.uploadSessions,) + .set({ status: 'failed', },) + .where(eq(schema.uploadSessions.id, sessionId,),) + return c.json({ + error: 'Schemas required', + message: 'First version must include a `schemas` map with at least one type definition.', + statusCode: 422, + }, 422,) + } - let schemasInput: Record; - if (session.schemas && Object.keys(session.schemas as object).length > 0) { - schemasInput = session.schemas as Record; - } else if (prevSchemaEntries.length > 0) { - schemasInput = Object.fromEntries(prevSchemaEntries.map((e) => [e.slug, e.schema])); + // Hash and upsert schemas + const newSchemaSet: { slug: string; schemaId: string; schemaHash: string; schema: Record }[] = [] + for (const [typeSlug, typeSchema,] of Object.entries(schemasInput,)) { + const hash = hashSchema(typeSchema,) + const [existing,] = await db + .select({ id: schema.schemas.id, },) + .from(schema.schemas,) + .where(eq(schema.schemas.schemaHash, hash,),) + .limit(1,) + + let schemaId: string + if (existing) { + schemaId = existing.id } else { - await db - .update(schema.uploadSessions) - .set({ status: "failed" }) - .where(eq(schema.uploadSessions.id, sessionId)); - return c.json({ - error: "Schemas required", - message: "First version must include a `schemas` map with at least one type definition.", - statusCode: 422, - }, 422); - } - - // Hash and upsert schemas - const newSchemaSet: { slug: string; schemaId: string; schemaHash: string; schema: Record }[] = []; - for (const [typeSlug, typeSchema] of Object.entries(schemasInput)) { - const hash = hashSchema(typeSchema); - const [existing] = await db - .select({ id: schema.schemas.id }) - .from(schema.schemas) - .where(eq(schema.schemas.schemaHash, hash)) - .limit(1); - - let schemaId: string; - if (existing) { - schemaId = existing.id; - } else { - const [inserted] = await db - .insert(schema.schemas) - .values({ schema: typeSchema as any, schemaHash: hash }) - .returning({ id: schema.schemas.id }); - schemaId = inserted!.id; - } - newSchemaSet.push({ slug: typeSlug, schemaId, schemaHash: hash, schema: typeSchema as Record }); + const [inserted,] = await db + .insert(schema.schemas,) + .values({ schema: typeSchema as any, schemaHash: hash, },) + .returning({ id: schema.schemas.id, },) + schemaId = inserted!.id } + newSchemaSet.push({ slug: typeSlug, schemaId, schemaHash: hash, schema: typeSchema as Record, },) + } - // Check schema changes - const prevSchemaMap = new Map(prevSchemaEntries.map((e) => [e.slug, e.schemaHash])); - const newSchemaMap = new Map(newSchemaSet.map((e) => [e.slug, e.schemaHash])); - let schemaChanged = prevSchemaMap.size !== newSchemaMap.size; - if (!schemaChanged) { - for (const [s, hash] of newSchemaMap) { - if (prevSchemaMap.get(s) !== hash) { - schemaChanged = true; - break; - } + // Check schema changes + const prevSchemaMap = new Map(prevSchemaEntries.map((e,) => [e.slug, e.schemaHash,]),) + const newSchemaMap = new Map(newSchemaSet.map((e,) => [e.slug, e.schemaHash,]),) + let schemaChanged = prevSchemaMap.size !== newSchemaMap.size + if (!schemaChanged) { + for (const [s, hash,] of newSchemaMap) { + if (prevSchemaMap.get(s,) !== hash) { + schemaChanged = true + break } } + } - // Build validators - const validators = new Map>(); - for (const entry of newSchemaSet) { - validators.set(entry.slug, ajv.compile(entry.schema as object)); - } + // Build validators + const validators = new Map>() + for (const entry of newSchemaSet) { + validators.set(entry.slug, ajv.compile(entry.schema as object,),) + } - // Get file hashes from previous version - let existingFileHashes: string[] = []; - if (latest) { - const vf = await db - .select({ hash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where(eq(schema.versionFiles.versionId, latest.id)); - existingFileHashes = vf.map((f) => f.hash); - } + // Get file hashes from previous version + let existingFileHashes: string[] = [] + if (latest) { + const vf = await db + .select({ hash: schema.versionFiles.fileHash, },) + .from(schema.versionFiles,) + .where(eq(schema.versionFiles.versionId, latest.id,),) + existingFileHashes = vf.map((f,) => f.hash) + } - // --- Streaming finalize --- - // Instead of loading all records into memory, we: - // 1. Materialize the merged record set into a temp table in Postgres - // 2. Stream through it in sorted batches for validation, hash computation, and insertion - // - // The temp table approach lets Postgres handle the merge (existing + staged changes) - // and gives us sorted cursor access without holding everything in Node memory. + // --- Streaming finalize --- + // Instead of loading all records into memory, we: + // 1. Materialize the merged record set into a temp table in Postgres + // 2. Stream through it in sorted batches for validation, hash computation, and insertion + // + // The temp table approach lets Postgres handle the merge (existing + staged changes) + // and gives us sorted cursor access without holding everything in Node memory. - // Create a temp table with the merged result - await db.execute(sql` + // Create a temp table with the merged result + await db.execute(sql` CREATE TEMP TABLE _finalize_records ( record_id text PRIMARY KEY, type text NOT NULL, data jsonb NOT NULL, private boolean NOT NULL DEFAULT false ) ON COMMIT DROP - `); + `,) - // Insert existing records from base version (if any) - if (latest) { - await db.execute(sql` + // Insert existing records from base version (if any) + if (latest) { + await db.execute(sql` INSERT INTO _finalize_records (record_id, type, data, private) SELECT record_id, type, data, private FROM records WHERE version_id = ${latest.id} - `); - } + `,) + } - // Apply staged changes (upserts and deletes) - await db.execute(sql` + // Apply staged changes (upserts and deletes) + await db.execute(sql` INSERT INTO _finalize_records (record_id, type, data, private) SELECT record_id, type, data, COALESCE(private, false) FROM upload_records @@ -445,373 +445,380 @@ export async function finalize(c: Context) { type = EXCLUDED.type, data = EXCLUDED.data, private = EXCLUDED.private - `); + `,) - // Remove deleted records - await db.execute(sql` + // Remove deleted records + await db.execute(sql` DELETE FROM _finalize_records WHERE record_id IN ( SELECT record_id FROM upload_records WHERE session_id = ${sessionId} AND operation = 'remove' ) - `); - - // Get total count - const [countResult] = await db.execute(sql`SELECT count(*) as cnt FROM _finalize_records`); - const totalRecordCount = Number((countResult as any).cnt); - - // Check all record types have schemas - const [typesResult] = await db.execute(sql`SELECT DISTINCT type FROM _finalize_records`); - // typesResult is an array of rows - const allTypes: string[] = (Array.isArray(typesResult) ? typesResult : [typesResult]) - .filter(Boolean) - .map((r: any) => r.type); - const missingSchemas = allTypes.filter((t) => !(t in schemasInput)); - if (missingSchemas.length > 0) { - await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - return c.json({ - error: "Missing schemas for record types", - types: missingSchemas, - statusCode: 422, - }, 422); - } + `,) + + // Get total count + const [countResult,] = await db.execute(sql`SELECT count(*) as cnt FROM _finalize_records`,) + const totalRecordCount = Number((countResult as any).cnt,) + + // Check all record types have schemas + const [typesResult,] = await db.execute(sql`SELECT DISTINCT type FROM _finalize_records`,) + // typesResult is an array of rows + const allTypes: string[] = (Array.isArray(typesResult,) ? typesResult : [typesResult,]) + .filter(Boolean,) + .map((r: any,) => r.type) + const missingSchemas = allTypes.filter((t,) => !(t in schemasInput)) + if (missingSchemas.length > 0) { + await db.update(schema.uploadSessions,).set({ status: 'failed', },).where( + eq(schema.uploadSessions.id, sessionId,), + ) + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) + return c.json({ + error: 'Missing schemas for record types', + types: missingSchemas, + statusCode: 422, + }, 422,) + } - // --- Stream through records in sorted batches --- - // We compute hashes incrementally and validate + collect file refs + insert records - const STREAM_BATCH = 5000; - const privateTypes = getPrivateTypes(newSchemaSet as SchemaEntry[]); - - // Streaming hash state - const privateHasher = createHash("sha256"); - const publicHasher = createHash("sha256"); - - // We build the canonical hash as: {"schemas":{...},"records":[],"files":[...],"readme":...} - // For streaming, we compute records portion incrementally - const schemaSetForHash = newSchemaSet - .map((e) => ({ slug: e.slug, schemaHash: e.schemaHash })) - .sort((a, b) => a.slug.localeCompare(b.slug)); - const publicSchemaSet = newSchemaSet - .filter((e) => !privateTypes.has(e.slug)) - .map((e) => ({ slug: e.slug, schemaHash: hashSchema(filterTypeSchema(e.schema)) })) - .sort((a, b) => a.slug.localeCompare(b.slug)); - - // We'll collect all record canonical forms for hashing - // Using incremental approach: hash prefix, then each record, then suffix - const schemasCanonical = JSON.stringify( - Object.fromEntries(schemaSetForHash.map((s) => [s.slug, s.schemaHash])), - ); - const publicSchemasCanonical = JSON.stringify( - Object.fromEntries(publicSchemaSet.map((s) => [s.slug, s.schemaHash])), - ); - - // Start building canonical: {"schemas":...,"records":[ - privateHasher.update(`{"schemas":${schemasCanonical},"records":[`); - publicHasher.update(`{"schemas":${publicSchemasCanonical},"records":[`); - - const referencedHashes = new Set(existingFileHashes); - const validationErrors: { recordId: string; type: string; errors: string[] }[] = []; - let totalBytes = 0; - let recordCount = 0; - let publicRecordCount = 0; - let hasChanges = false; - let cursor = ""; - let hasMore = true; - - // Check if staged records exist (indicates changes) - const [stagedCount] = await db - .select({ count: sql`count(*)` }) - .from(schema.uploadRecords) - .where(eq(schema.uploadRecords.sessionId, sessionId)); - hasChanges = (stagedCount?.count ?? 0) > 0; - - // Insert the new version early to get its ID for record insertion - // We'll update the hash fields after streaming - const readmeValue = session.readme !== null ? session.readme : (latest?.readme ?? null); - const semver = deriveSemver(latest?.semver ?? null, schemaChanged, hasChanges); - const newNumber = currentNumber + 1; - - // We need to process all records before we can insert the version (need hashes) - // So we stream in two phases: - // Phase 1: validate + compute hashes + collect file refs + count bytes - // Phase 2: insert records (re-stream from temp table) - - while (hasMore) { - const batch = await db.execute(sql` + // --- Stream through records in sorted batches --- + // We compute hashes incrementally and validate + collect file refs + insert records + const STREAM_BATCH = 5000 + const privateTypes = getPrivateTypes(newSchemaSet as SchemaEntry[],) + + // Streaming hash state + const privateHasher = createHash('sha256',) + const publicHasher = createHash('sha256',) + + // We build the canonical hash as: {"schemas":{...},"records":[],"files":[...],"readme":...} + // For streaming, we compute records portion incrementally + const schemaSetForHash = newSchemaSet + .map((e,) => ({ slug: e.slug, schemaHash: e.schemaHash, })) + .sort((a, b,) => a.slug.localeCompare(b.slug,)) + const publicSchemaSet = newSchemaSet + .filter((e,) => !privateTypes.has(e.slug,)) + .map((e,) => ({ slug: e.slug, schemaHash: hashSchema(filterTypeSchema(e.schema,),), })) + .sort((a, b,) => a.slug.localeCompare(b.slug,)) + + // We'll collect all record canonical forms for hashing + // Using incremental approach: hash prefix, then each record, then suffix + const schemasCanonical = JSON.stringify( + Object.fromEntries(schemaSetForHash.map((s,) => [s.slug, s.schemaHash,]),), + ) + const publicSchemasCanonical = JSON.stringify( + Object.fromEntries(publicSchemaSet.map((s,) => [s.slug, s.schemaHash,]),), + ) + + // Start building canonical: {"schemas":...,"records":[ + privateHasher.update(`{"schemas":${schemasCanonical},"records":[`,) + publicHasher.update(`{"schemas":${publicSchemasCanonical},"records":[`,) + + const referencedHashes = new Set(existingFileHashes,) + const validationErrors: { recordId: string; type: string; errors: string[] }[] = [] + let totalBytes = 0 + let recordCount = 0 + let publicRecordCount = 0 + let hasChanges = false + let cursor = '' + let hasMore = true + + // Check if staged records exist (indicates changes) + const [stagedCount,] = await db + .select({ count: sql`count(*)`, },) + .from(schema.uploadRecords,) + .where(eq(schema.uploadRecords.sessionId, sessionId,),) + hasChanges = (stagedCount?.count ?? 0) > 0 + + // Insert the new version early to get its ID for record insertion + // We'll update the hash fields after streaming + const readmeValue = session.readme !== null ? session.readme : (latest?.readme ?? null) + const semver = deriveSemver(latest?.semver ?? null, schemaChanged, hasChanges,) + const newNumber = currentNumber + 1 + + // We need to process all records before we can insert the version (need hashes) + // So we stream in two phases: + // Phase 1: validate + compute hashes + collect file refs + count bytes + // Phase 2: insert records (re-stream from temp table) + + while (hasMore) { + const batch = await db.execute(sql` SELECT record_id, type, data, private FROM _finalize_records WHERE record_id > ${cursor} ORDER BY record_id ASC LIMIT ${STREAM_BATCH} - `) as any[]; + `,) as any[] - const rows = Array.isArray(batch) ? batch : []; - if (rows.length === 0) { - hasMore = false; - break; - } + const rows = Array.isArray(batch,) ? batch : [] + if (rows.length === 0) { + hasMore = false + break + } - for (const rec of rows) { - // Validate - const validate = validators.get(rec.type); - if (!validate) { - validationErrors.push({ - recordId: rec.record_id, - type: rec.type, - errors: [`No schema defined for record type "${rec.type}"`], - }); - } else if (!validate(rec.data)) { - validationErrors.push({ - recordId: rec.record_id, - type: rec.type, - errors: (validate.errors ?? []).map( - (e) => `${e.instancePath || "/"} ${e.message ?? "validation failed"}`, - ), - }); - } + for (const rec of rows) { + // Validate + const validate = validators.get(rec.type,) + if (!validate) { + validationErrors.push({ + recordId: rec.record_id, + type: rec.type, + errors: [`No schema defined for record type "${rec.type}"`,], + },) + } else if (!validate(rec.data,)) { + validationErrors.push({ + recordId: rec.record_id, + type: rec.type, + errors: (validate.errors ?? []).map( + (e,) => `${e.instancePath || '/'} ${e.message ?? 'validation failed'}`, + ), + },) + } - // Feed into private hash (all records) - const recCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: rec.data }); - if (recordCount > 0) privateHasher.update(","); - privateHasher.update(recCanonical); - recordCount++; - - // Feed into public hash (non-private records only, with private fields stripped) - const isPrivateRecord = rec.private === true; - const isPrivateType = privateTypes.has(rec.type); - if (!isPrivateRecord && !isPrivateType) { - const entry = newSchemaSet.find((e) => e.slug === rec.type); - const privFields = entry ? getPrivateFields(entry.schema) : new Set(); - const pubData = privFields.size > 0 ? filterRecordData(rec.data, privFields) : rec.data; - const pubCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: pubData }); - if (publicRecordCount > 0) publicHasher.update(","); - publicHasher.update(pubCanonical); - publicRecordCount++; - } + // Feed into private hash (all records) + const recCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: rec.data, },) + if (recordCount > 0) privateHasher.update(',',) + privateHasher.update(recCanonical,) + recordCount++ + + // Feed into public hash (non-private records only, with private fields stripped) + const isPrivateRecord = rec.private === true + const isPrivateType = privateTypes.has(rec.type,) + if (!isPrivateRecord && !isPrivateType) { + const entry = newSchemaSet.find((e,) => e.slug === rec.type) + const privFields = entry ? getPrivateFields(entry.schema,) : new Set() + const pubData = privFields.size > 0 ? filterRecordData(rec.data, privFields,) : rec.data + const pubCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: pubData, },) + if (publicRecordCount > 0) publicHasher.update(',',) + publicHasher.update(pubCanonical,) + publicRecordCount++ + } - // Compute bytes - totalBytes += Buffer.byteLength(JSON.stringify(rec.data), "utf-8"); - - // Scan for $file references - const data = rec.data as Record; - for (const val of Object.values(data)) { - if ( - typeof val === "object" && - val !== null && - "$file" in val && - typeof (val as { $file: string }).$file === "string" - ) { - const fileHash = (val as { $file: string }).$file.replace("sha256:", ""); - referencedHashes.add(fileHash); - } + // Compute bytes + totalBytes += Buffer.byteLength(JSON.stringify(rec.data,), 'utf-8',) + + // Scan for $file references + const data = rec.data as Record + for (const val of Object.values(data,)) { + if ( + typeof val === 'object' + && val !== null + && '$file' in val + && typeof (val as { $file: string }).$file === 'string' + ) { + const fileHash = (val as { $file: string }).$file.replace('sha256:', '',) + referencedHashes.add(fileHash,) } } - - cursor = rows[rows.length - 1].record_id; - if (rows.length < STREAM_BATCH) hasMore = false; } - // Bail on validation errors - if (validationErrors.length > 0) { - await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - return c.json({ - error: "Schema validation failed", - validationErrors: validationErrors.slice(0, 100), // cap error list - statusCode: 422, - }, 422); - } + cursor = rows[rows.length - 1].record_id + if (rows.length < STREAM_BATCH) hasMore = false + } - // Check all referenced files exist - const allFileHashes = Array.from(referencedHashes); - if (allFileHashes.length > 0) { - const existingFiles = await db - .select({ hash: schema.files.hash }) - .from(schema.files) - .where(inArray(schema.files.hash, allFileHashes)); - const existingSet = new Set(existingFiles.map((f) => f.hash)); - let filesNeeded = allFileHashes.filter((h) => !existingSet.has(h)); - - // For files not in local DB, check if they exist in S3 (shared bucket) - if (filesNeeded.length > 0) { - const stillNeeded: string[] = []; - for (const h of filesNeeded) { - const key = `files/${h.slice(0, 2)}/${h.slice(2, 4)}/${h}`; - const meta = await getS3ObjectMeta(key); - if (meta !== null) { - await db.insert(schema.files).values({ - hash: h, - size: meta.size, - mimeType: meta.contentType, - storageKey: key, - }).onConflictDoNothing(); - } else { - stillNeeded.push(h); - } - } - filesNeeded = stillNeeded; - } + // Bail on validation errors + if (validationErrors.length > 0) { + await db.update(schema.uploadSessions,).set({ status: 'failed', },).where( + eq(schema.uploadSessions.id, sessionId,), + ) + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) + return c.json({ + error: 'Schema validation failed', + validationErrors: validationErrors.slice(0, 100,), // cap error list + statusCode: 422, + }, 422,) + } - if (filesNeeded.length > 0) { - await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - return c.json({ - error: "Missing files", - filesNeeded: filesNeeded.map((h) => `sha256:${h}`), - statusCode: 422, - }, 422); + // Check all referenced files exist + const allFileHashes = Array.from(referencedHashes,) + if (allFileHashes.length > 0) { + const existingFiles = await db + .select({ hash: schema.files.hash, },) + .from(schema.files,) + .where(inArray(schema.files.hash, allFileHashes,),) + const existingSet = new Set(existingFiles.map((f,) => f.hash),) + let filesNeeded = allFileHashes.filter((h,) => !existingSet.has(h,)) + + // For files not in local DB, check if they exist in S3 (shared bucket) + if (filesNeeded.length > 0) { + const stillNeeded: string[] = [] + for (const h of filesNeeded) { + const key = `files/${h.slice(0, 2,)}/${h.slice(2, 4,)}/${h}` + const meta = await getS3ObjectMeta(key,) + if (meta !== null) { + await db.insert(schema.files,).values({ + hash: h, + size: meta.size, + mimeType: meta.contentType, + storageKey: key, + },).onConflictDoNothing() + } else { + stillNeeded.push(h,) + } } + filesNeeded = stillNeeded } - // Finalize hash computation - const sortedFileHashes = allFileHashes.sort(); - const filesCanonical = JSON.stringify(sortedFileHashes); - const readmeCanonical = JSON.stringify(readmeValue ?? null); - - privateHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`); - publicHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`); - - const versionHash = "private:" + privateHasher.digest("hex"); - const publicHash = "public:" + publicHasher.digest("hex"); - - // Check for duplicate hash - const [existingHash] = await db - .select({ number: schema.versions.number }) - .from(schema.versions) - .where( - and( - eq(schema.versions.collectionId, collection.id), - eq(schema.versions.hash, versionHash), - ), + if (filesNeeded.length > 0) { + await db.update(schema.uploadSessions,).set({ status: 'failed', },).where( + eq(schema.uploadSessions.id, sessionId,), ) - .limit(1); - - if (existingHash) { - await db.update(schema.uploadSessions).set({ status: "failed" }).where(eq(schema.uploadSessions.id, sessionId)); - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) return c.json({ - error: "No changes detected", - message: `Version ${existingHash.number} already has identical content`, - existingVersion: existingHash.number, - }, 409); - } - - // Add file sizes to totalBytes - if (allFileHashes.length > 0) { - const [fileSizeSum] = await db - .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) - .from(schema.files) - .where(inArray(schema.files.hash, allFileHashes)); - totalBytes += Number(fileSizeSum?.total ?? 0); - } - - // Insert version - const [version] = await db - .insert(schema.versions) - .values({ - collectionId: collection.id, - number: newNumber, - semver, - hash: versionHash, - publicHash, - baseNumber: session.baseVersion, - message: session.message ?? null, - readme: readmeValue, - pushedBy: c.get("accountId") ?? null, - appId: session.appId ?? null, - actorId: session.actorId ?? null, - recordCount, - fileCount: allFileHashes.length, - totalBytes, - }) - .returning(); - - // Phase 2: Insert records from temp table into the real records table (in batches) - await db.execute(sql` - INSERT INTO records (version_id, record_id, type, data, private) - SELECT ${version!.id}, record_id, type, data, private - FROM _finalize_records - `); - - // Clean up temp table - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - - // Insert version_files - if (allFileHashes.length > 0) { - await db.insert(schema.versionFiles).values( - allFileHashes.map((hash) => ({ - versionId: version!.id, - fileHash: hash, - })), - ); + error: 'Missing files', + filesNeeded: filesNeeded.map((h,) => `sha256:${h}`), + statusCode: 422, + }, 422,) } + } - // Insert version_schemas - await db.insert(schema.versionSchemas).values( - newSchemaSet.map((entry) => ({ - versionId: version!.id, - slug: entry.slug, - schemaId: entry.schemaId, - })), - ); - - // Update collection timestamp - await db - .update(schema.collections) - .set({ updatedAt: new Date() }) - .where(eq(schema.collections.id, collection.id)); + // Finalize hash computation + const sortedFileHashes = allFileHashes.sort() + const filesCanonical = JSON.stringify(sortedFileHashes,) + const readmeCanonical = JSON.stringify(readmeValue ?? null,) + + privateHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`,) + publicHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`,) + + const versionHash = 'private:' + privateHasher.digest('hex',) + const publicHash = 'public:' + publicHasher.digest('hex',) + + // Check for duplicate hash + const [existingHash,] = await db + .select({ number: schema.versions.number, },) + .from(schema.versions,) + .where( + and( + eq(schema.versions.collectionId, collection.id,), + eq(schema.versions.hash, versionHash,), + ), + ) + .limit(1,) + + if (existingHash) { + await db.update(schema.uploadSessions,).set({ status: 'failed', },).where( + eq(schema.uploadSessions.id, sessionId,), + ) + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) + return c.json({ + error: 'No changes detected', + message: `Version ${existingHash.number} already has identical content`, + existingVersion: existingHash.number, + }, 409,) + } - // Clean up: delete staged records and the session itself - await db - .delete(schema.uploadRecords) - .where(eq(schema.uploadRecords.sessionId, sessionId)); - await db - .delete(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)); + // Add file sizes to totalBytes + if (allFileHashes.length > 0) { + const [fileSizeSum,] = await db + .select({ total: sql`coalesce(sum(${schema.files.size}), 0)`, },) + .from(schema.files,) + .where(inArray(schema.files.hash, allFileHashes,),) + totalBytes += Number(fileSizeSum?.total ?? 0,) + } - return c.json({ - version: newNumber, + // Insert version + const [version,] = await db + .insert(schema.versions,) + .values({ + collectionId: collection.id, + number: newNumber, semver, hash: versionHash, + publicHash, + baseNumber: session.baseVersion, + message: session.message ?? null, + readme: readmeValue, + pushedBy: c.get('accountId',) ?? null, + appId: session.appId ?? null, + actorId: session.actorId ?? null, recordCount, fileCount: allFileHashes.length, - }, 201); - } catch (err) { - // Mark session as failed on unexpected error - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`); - await db - .update(schema.uploadSessions) - .set({ status: "failed" }) - .where(eq(schema.uploadSessions.id, sessionId)); - throw err; - } -} + totalBytes, + },) + .returning() -// --- Abort/cancel a session --- -export async function cancelSession(c: Context) { - const sessionId = c.req.param("sessionId")!; + // Phase 2: Insert records from temp table into the real records table (in batches) + await db.execute(sql` + INSERT INTO records (version_id, record_id, type, data, private) + SELECT ${version!.id}, record_id, type, data, private + FROM _finalize_records + `,) - const [session] = await db - .select() - .from(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)) - .limit(1); + // Clean up temp table + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) - if (!session) { - return c.json({ error: "Upload session not found", statusCode: 404 }, 404); - } - if (session.accountId !== c.get("accountId")) { - return c.json({ error: "Not authorized for this session", statusCode: 403 }, 403); + // Insert version_files + if (allFileHashes.length > 0) { + await db.insert(schema.versionFiles,).values( + allFileHashes.map((hash,) => ({ + versionId: version!.id, + fileHash: hash, + })), + ) } - // Delete staged records and session + // Insert version_schemas + await db.insert(schema.versionSchemas,).values( + newSchemaSet.map((entry,) => ({ + versionId: version!.id, + slug: entry.slug, + schemaId: entry.schemaId, + })), + ) + + // Update collection timestamp + await db + .update(schema.collections,) + .set({ updatedAt: new Date(), },) + .where(eq(schema.collections.id, collection.id,),) + + // Clean up: delete staged records and the session itself await db - .delete(schema.uploadRecords) - .where(eq(schema.uploadRecords.sessionId, sessionId)); + .delete(schema.uploadRecords,) + .where(eq(schema.uploadRecords.sessionId, sessionId,),) await db - .delete(schema.uploadSessions) - .where(eq(schema.uploadSessions.id, sessionId)); + .delete(schema.uploadSessions,) + .where(eq(schema.uploadSessions.id, sessionId,),) - return c.body(null, 204); + return c.json({ + version: newNumber, + semver, + hash: versionHash, + recordCount, + fileCount: allFileHashes.length, + }, 201,) + } catch (err) { + // Mark session as failed on unexpected error + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) + await db + .update(schema.uploadSessions,) + .set({ status: 'failed', },) + .where(eq(schema.uploadSessions.id, sessionId,),) + throw err + } } +// --- Abort/cancel a session --- +export async function cancelSession(c: Context,) { + const sessionId = c.req.param('sessionId',)! + + const [session,] = await db + .select() + .from(schema.uploadSessions,) + .where(eq(schema.uploadSessions.id, sessionId,),) + .limit(1,) + + if (!session) { + return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + } + if (session.accountId !== c.get('accountId',)) { + return c.json({ error: 'Not authorized for this session', statusCode: 403, }, 403,) + } + + // Delete staged records and session + await db + .delete(schema.uploadRecords,) + .where(eq(schema.uploadRecords.sessionId, sessionId,),) + await db + .delete(schema.uploadSessions,) + .where(eq(schema.uploadSessions.id, sessionId,),) + + return c.body(null, 204,) +} diff --git a/src/api/versions.ts b/src/api/versions.ts index 99a625c..a89470a 100644 --- a/src/api/versions.ts +++ b/src/api/versions.ts @@ -1,34 +1,34 @@ -import type { Context } from 'hono' -import { eq, and, sql, inArray } from "drizzle-orm"; -import { db, schema } from "../db/client.server.js"; -import { type AuthEnv } from "./auth.server.js"; -import { createHash } from "node:crypto"; -import { DEFAULT_NAAN, buildArkUrl } from "../lib/ark.js"; +import { and, eq, inArray, sql, } from 'drizzle-orm' +import type { Context, } from 'hono' +import { createHash, } from 'node:crypto' +import { db, schema, } from '../db/client.server.js' +import { buildArkUrl, DEFAULT_NAAN, } from '../lib/ark.js' import { ajv, - type SchemaEntry, - loadVersionSchemas, - getPrivateTypes, - getPrivateFields, + deriveSemver, filterRecordData, filterTypeSchema, + getPrivateFields, + getPrivateTypes, hashSchema, - deriveSemver, + loadVersionSchemas, + type SchemaEntry, } from '../lib/version-helpers.server.js' +import { type AuthEnv, } from './auth.server.js' /** Build a public-facing schemas map (excluding private types, stripping private fields) */ -function filterSchemasForPublic(schemaEntries: SchemaEntry[]): Record { - const result: Record = {}; +function filterSchemasForPublic(schemaEntries: SchemaEntry[],): Record { + const result: Record = {} for (const entry of schemaEntries) { - if ((entry.schema as any)?.private === true) continue; - result[entry.slug] = filterTypeSchema(entry.schema); + if ((entry.schema as any)?.private === true) continue + result[entry.slug] = filterTypeSchema(entry.schema,) } - return result; + return result } /** Check if requester is the owner of a collection */ -function isOwner(accountId: string | undefined, collectionAccountId: string): boolean { - return accountId != null && accountId === collectionAccountId; +function isOwner(accountId: string | undefined, collectionAccountId: string,): boolean { + return accountId != null && accountId === collectionAccountId } function computeVersionHash( @@ -39,15 +39,15 @@ function computeVersionHash( ): string { const canonical = JSON.stringify({ schemas: Object.fromEntries( - schemaSet.sort((a, b) => a.slug.localeCompare(b.slug)).map((s) => [s.slug, s.schemaHash]), + schemaSet.sort((a, b,) => a.slug.localeCompare(b.slug,)).map((s,) => [s.slug, s.schemaHash,]), ), records: recordRows - .sort((a, b) => a.recordId.localeCompare(b.recordId)) - .map((r) => ({ id: r.recordId, type: r.type, data: r.data })), + .sort((a, b,) => a.recordId.localeCompare(b.recordId,)) + .map((r,) => ({ id: r.recordId, type: r.type, data: r.data, })), files: fileHashes.sort(), readme: readme ?? null, - }); - return "private:" + createHash("sha256").update(canonical).digest("hex"); + },) + return 'private:' + createHash('sha256',).update(canonical,).digest('hex',) } /** Compute a public hash that only covers non-private content */ @@ -57,83 +57,85 @@ function computePublicHash( fileHashes: string[], readme: string | null, ): string { - const privateTypes = getPrivateTypes(schemaEntries); + const privateTypes = getPrivateTypes(schemaEntries,) // Build public schema set (non-private types, with private fields stripped) - const publicSchemaSet: { slug: string; schemaHash: string }[] = []; + const publicSchemaSet: { slug: string; schemaHash: string }[] = [] for (const entry of schemaEntries) { - if (privateTypes.has(entry.slug)) continue; - const filtered = filterTypeSchema(entry.schema); - publicSchemaSet.push({ slug: entry.slug, schemaHash: hashSchema(filtered) }); + if (privateTypes.has(entry.slug,)) continue + const filtered = filterTypeSchema(entry.schema,) + publicSchemaSet.push({ slug: entry.slug, schemaHash: hashSchema(filtered,), },) } // Filter to public records only, and strip private fields const publicRecords = recordRows - .filter((r) => !r.private && !privateTypes.has(r.type)) - .map((r) => { - const entry = schemaEntries.find((e) => e.slug === r.type); - const privateFields = entry ? getPrivateFields(entry.schema) : new Set(); - const data = privateFields.size > 0 ? filterRecordData(r.data, privateFields) : r.data; - return { id: r.recordId, type: r.type, data }; - }) - .sort((a, b) => a.id.localeCompare(b.id)); + .filter((r,) => !r.private && !privateTypes.has(r.type,)) + .map((r,) => { + const entry = schemaEntries.find((e,) => e.slug === r.type) + const privateFields = entry ? getPrivateFields(entry.schema,) : new Set() + const data = privateFields.size > 0 ? filterRecordData(r.data, privateFields,) : r.data + return { id: r.recordId, type: r.type, data, } + },) + .sort((a, b,) => a.id.localeCompare(b.id,)) const canonical = JSON.stringify({ schemas: Object.fromEntries( - publicSchemaSet.sort((a, b) => a.slug.localeCompare(b.slug)).map((s) => [s.slug, s.schemaHash]), + publicSchemaSet.sort((a, b,) => a.slug.localeCompare(b.slug,)).map((s,) => [s.slug, s.schemaHash,]), ), records: publicRecords, files: fileHashes.sort(), readme: readme ?? null, - }); - return "public:" + createHash("sha256").update(canonical).digest("hex"); + },) + return 'public:' + createHash('sha256',).update(canonical,).digest('hex',) } // Lazily backfill totalBytes for versions that were created before we tracked it // or where the value was corrupted by a string concatenation bug -async function backfillTotalBytes(version: { id: number; totalBytes: number; recordCount: number }) { +async function backfillTotalBytes(version: { id: number; totalBytes: number; recordCount: number },) { // Skip recomputation if totalBytes looks reasonable (> 0 and < 1TB) - if (version.totalBytes > 0 && version.totalBytes < 1_099_511_627_776 || version.recordCount === 0) return version.totalBytes; + if (version.totalBytes > 0 && version.totalBytes < 1_099_511_627_776 || version.recordCount === 0) { + return version.totalBytes + } const records = await db - .select({ data: schema.records.data }) - .from(schema.records) - .where(eq(schema.records.versionId, version.id)); + .select({ data: schema.records.data, },) + .from(schema.records,) + .where(eq(schema.records.versionId, version.id,),) - let totalBytes = 0; + let totalBytes = 0 for (const r of records) { - totalBytes += Buffer.byteLength(JSON.stringify(r.data), "utf-8"); + totalBytes += Buffer.byteLength(JSON.stringify(r.data,), 'utf-8',) } - const [fileSizeResult] = await db - .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) - .from(schema.versionFiles) - .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) - .where(eq(schema.versionFiles.versionId, version.id)); - totalBytes += Number(fileSizeResult?.total ?? 0); + const [fileSizeResult,] = await db + .select({ total: sql`coalesce(sum(${schema.files.size}), 0)`, },) + .from(schema.versionFiles,) + .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash,),) + .where(eq(schema.versionFiles.versionId, version.id,),) + totalBytes += Number(fileSizeResult?.total ?? 0,) // Persist so we don't recompute next time await db - .update(schema.versions) - .set({ totalBytes }) - .where(eq(schema.versions.id, version.id)); + .update(schema.versions,) + .set({ totalBytes, },) + .where(eq(schema.versions.id, version.id,),) - return totalBytes; + return totalBytes } // List versions -export async function list(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const limit = c.req.query("limit"); - const offset = c.req.query("offset"); +export async function list(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const limit = c.req.query('limit',) + const offset = c.req.query('offset',) - const collection = await resolveCollection(owner, slug); - if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + const collection = await resolveCollection(owner, slug,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) - const accountId = c.get("accountId"); - const ownerAccess = isOwner(accountId, collection.accountId); - const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); + const accountId = c.get('accountId',) + const ownerAccess = isOwner(accountId, collection.accountId,) + const arkInfo = await getCollectionArkInfo(collection.id,).catch(() => null) const rows = await db .select({ @@ -148,14 +150,14 @@ export async function list(c: Context) { fileCount: schema.versions.fileCount, totalBytes: schema.versions.totalBytes, createdAt: schema.versions.createdAt, - }) - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(Math.min(parseInt(limit ?? "50", 10), 100)) - .offset(parseInt(offset ?? "0", 10)); - - return c.json(rows.map((row) => ({ + },) + .from(schema.versions,) + .where(eq(schema.versions.collectionId, collection.id,),) + .orderBy(sql`${schema.versions.number} desc`,) + .limit(Math.min(parseInt(limit ?? '50', 10,), 100,),) + .offset(parseInt(offset ?? '0', 10,),) + + return c.json(rows.map((row,) => ({ number: row.number, semver: row.semver, hash: ownerAccess ? row.hash : (row.publicHash ?? row.hash), @@ -166,185 +168,188 @@ export async function list(c: Context) { fileCount: row.fileCount, totalBytes: row.totalBytes, createdAt: row.createdAt, - ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, row.number) : null, - }))); + ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, row.number,) : null, + })),) } // Latest version -export async function latest(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const collection = await resolveCollection(owner, slug); - if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); +export async function latest(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const collection = await resolveCollection(owner, slug,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) - const [version] = await db + const [version,] = await db .select() - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); + .from(schema.versions,) + .where(eq(schema.versions.collectionId, collection.id,),) + .orderBy(sql`${schema.versions.number} desc`,) + .limit(1,) - if (!version) return c.json({ error: "No versions", statusCode: 404 }, 404); - version.totalBytes = await backfillTotalBytes(version); + if (!version) return c.json({ error: 'No versions', statusCode: 404, }, 404,) + version.totalBytes = await backfillTotalBytes(version,) - const schemaEntries = await loadVersionSchemas(version.id); - const accountId = c.get("accountId"); - const ownerAccess = isOwner(accountId, collection.accountId); - const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); + const schemaEntries = await loadVersionSchemas(version.id,) + const accountId = c.get('accountId',) + const ownerAccess = isOwner(accountId, collection.accountId,) + const arkInfo = await getCollectionArkInfo(collection.id,).catch(() => null) const schemasMap = ownerAccess - ? Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schema])) - : filterSchemasForPublic(schemaEntries); + ? Object.fromEntries(schemaEntries.map((e,) => [e.slug, e.schema,]),) + : filterSchemasForPublic(schemaEntries,) return c.json({ ...version, hash: ownerAccess ? version.hash : (version.publicHash ?? version.hash), schemas: schemasMap, - ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number) : null, - }); + ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number,) : null, + },) } // Get version by number -export async function getByNumber(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const n = c.req.param("n")!; - const collection = await resolveCollection(owner, slug); - if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); - - const [version] = await db +export async function getByNumber(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const n = c.req.param('n',)! + const collection = await resolveCollection(owner, slug,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + + const [version,] = await db .select() - .from(schema.versions) + .from(schema.versions,) .where( - and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), + and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), ) - .limit(1); + .limit(1,) - if (!version) return c.json({ error: "Version not found", statusCode: 404 }, 404); - version.totalBytes = await backfillTotalBytes(version); + if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) + version.totalBytes = await backfillTotalBytes(version,) - const schemaEntries = await loadVersionSchemas(version.id); - const accountId = c.get("accountId"); - const ownerAccess = isOwner(accountId, collection.accountId); - const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); + const schemaEntries = await loadVersionSchemas(version.id,) + const accountId = c.get('accountId',) + const ownerAccess = isOwner(accountId, collection.accountId,) + const arkInfo = await getCollectionArkInfo(collection.id,).catch(() => null) const schemasMap = ownerAccess - ? Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schema])) - : filterSchemasForPublic(schemaEntries); + ? Object.fromEntries(schemaEntries.map((e,) => [e.slug, e.schema,]),) + : filterSchemasForPublic(schemaEntries,) return c.json({ ...version, hash: ownerAccess ? version.hash : (version.publicHash ?? version.hash), schemas: schemasMap, - ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number) : null, - }); + ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number,) : null, + },) } // Get records for a version -export async function records(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const n = c.req.param("n")!; - const type = c.req.query("type"); - const limit = c.req.query("limit"); - const offset = c.req.query("offset"); - const after = c.req.query("after"); - - const collection = await resolveCollection(owner, slug); - if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); - - const [version] = await db +export async function records(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const n = c.req.param('n',)! + const type = c.req.query('type',) + const limit = c.req.query('limit',) + const offset = c.req.query('offset',) + const after = c.req.query('after',) + + const collection = await resolveCollection(owner, slug,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + + const [version,] = await db .select() - .from(schema.versions) + .from(schema.versions,) .where( - and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), + and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), ) - .limit(1); + .limit(1,) - if (!version) return c.json({ error: "Version not found", statusCode: 404 }, 404); + if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) - const conditions = [eq(schema.records.versionId, version.id)]; - if (type) conditions.push(eq(schema.records.type, type)); + const conditions = [eq(schema.records.versionId, version.id,),] + if (type) conditions.push(eq(schema.records.type, type,),) // Cursor-based pagination: ?after=recordId (keyset pagination) if (after) { - conditions.push(sql`${schema.records.recordId} > ${after}`); + conditions.push(sql`${schema.records.recordId} > ${after}`,) } // Determine visibility - const accountId = c.get("accountId"); - const ownerAccess = isOwner(accountId, collection.accountId); + const accountId = c.get('accountId',) + const ownerAccess = isOwner(accountId, collection.accountId,) - let privateTypes = new Set(); - let schemaEntries: SchemaEntry[] = []; + let privateTypes = new Set() + let schemaEntries: SchemaEntry[] = [] if (!ownerAccess) { - schemaEntries = await loadVersionSchemas(version.id); - privateTypes = getPrivateTypes(schemaEntries); + schemaEntries = await loadVersionSchemas(version.id,) + privateTypes = getPrivateTypes(schemaEntries,) if (privateTypes.size > 0) { - if (type && privateTypes.has(type)) { - return c.json([]); // requesting a private type as non-owner + if (type && privateTypes.has(type,)) { + return c.json([],) // requesting a private type as non-owner } for (const pt of privateTypes) { - conditions.push(sql`${schema.records.type} != ${pt}`); + conditions.push(sql`${schema.records.type} != ${pt}`,) } } // Exclude record-level private records - conditions.push(eq(schema.records.private, false)); + conditions.push(eq(schema.records.private, false,),) } - const pageLimit = Math.min(parseInt(limit ?? "100", 10), 1000); + const pageLimit = Math.min(parseInt(limit ?? '100', 10,), 1000,) const records = await db .select({ id: schema.records.recordId, type: schema.records.type, data: schema.records.data, - }) - .from(schema.records) - .where(and(...conditions)) - .orderBy(schema.records.recordId) - .limit(pageLimit + 1) - .offset(after ? 0 : parseInt(offset ?? "0", 10)); + },) + .from(schema.records,) + .where(and(...conditions,),) + .orderBy(schema.records.recordId,) + .limit(pageLimit + 1,) + .offset(after ? 0 : parseInt(offset ?? '0', 10,),) // Determine if there's a next page - const hasMore = records.length > pageLimit; - const page = hasMore ? records.slice(0, pageLimit) : records; - const nextCursor = hasMore ? page[page.length - 1]!.id : null; + const hasMore = records.length > pageLimit + const page = hasMore ? records.slice(0, pageLimit,) : records + const nextCursor = hasMore ? page[page.length - 1]!.id : null // Strip private fields if not owner - let resultRecords = page; + let resultRecords = page if (!ownerAccess) { - const fieldCache = new Map>(); - resultRecords = page.map((rec) => { - if (!fieldCache.has(rec.type)) { - const entry = schemaEntries.find((e) => e.slug === rec.type); - fieldCache.set(rec.type, entry ? getPrivateFields(entry.schema) : new Set()); + const fieldCache = new Map>() + resultRecords = page.map((rec,) => { + if (!fieldCache.has(rec.type,)) { + const entry = schemaEntries.find((e,) => e.slug === rec.type) + fieldCache.set(rec.type, entry ? getPrivateFields(entry.schema,) : new Set(),) } - const privateFields = fieldCache.get(rec.type)!; + const privateFields = fieldCache.get(rec.type,)! return privateFields.size > 0 - ? { ...rec, data: filterRecordData(rec.data, privateFields) } - : rec; - }); + ? { ...rec, data: filterRecordData(rec.data, privateFields,), } + : rec + },) } // Add ARK URLs for record types that have ARKs enabled - const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null); - let arkEnabledTypes = new Map(); // recordType → redirectUrlField + const arkInfo = await getCollectionArkInfo(collection.id,).catch(() => null) + let arkEnabledTypes = new Map() // recordType → redirectUrlField if (arkInfo) { const artRows = await db - .select({ recordType: schema.arkRecordTypes.recordType, redirectUrlField: schema.arkRecordTypes.redirectUrlField }) - .from(schema.arkRecordTypes) - .where(eq(schema.arkRecordTypes.collectionId, collection.id)); - for (const r of artRows) arkEnabledTypes.set(r.recordType, r.redirectUrlField); + .select({ + recordType: schema.arkRecordTypes.recordType, + redirectUrlField: schema.arkRecordTypes.redirectUrlField, + },) + .from(schema.arkRecordTypes,) + .where(eq(schema.arkRecordTypes.collectionId, collection.id,),) + for (const r of artRows) arkEnabledTypes.set(r.recordType, r.redirectUrlField,) } - const recordsWithArk = resultRecords.map((rec) => { - const ark = arkInfo && arkEnabledTypes.has(rec.type) - ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number, rec.type, rec.id) - : null; - return ark ? { ...rec, ark } : rec; - }); + const recordsWithArk = resultRecords.map((rec,) => { + const ark = arkInfo && arkEnabledTypes.has(rec.type,) + ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number, rec.type, rec.id,) + : null + return ark ? { ...rec, ark, } : rec + },) return c.json({ records: recordsWithArk, @@ -354,26 +359,26 @@ export async function records(c: Context) { nextCursor, total: version.recordCount, }, - }); + },) } // List files for a version -export async function files(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const n = c.req.param("n")!; - const collection = await resolveCollection(owner, slug); - if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); - - const [version] = await db +export async function files(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const n = c.req.param('n',)! + const collection = await resolveCollection(owner, slug,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + + const [version,] = await db .select() - .from(schema.versions) + .from(schema.versions,) .where( - and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), + and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), ) - .limit(1); + .limit(1,) - if (!version) return c.json({ error: "Version not found", statusCode: 404 }, 404); + if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) const fileRows = await db .select({ @@ -381,563 +386,563 @@ export async function files(c: Context) { size: schema.files.size, mimeType: schema.files.mimeType, createdAt: schema.files.createdAt, - }) - .from(schema.versionFiles) - .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) - .where(eq(schema.versionFiles.versionId, version.id)); + },) + .from(schema.versionFiles,) + .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash,),) + .where(eq(schema.versionFiles.versionId, version.id,),) // Build file→record reference map by scanning record data for $file refs const allRecords = await db - .select({ recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data }) - .from(schema.records) - .where(eq(schema.records.versionId, version.id)); + .select({ recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data, },) + .from(schema.records,) + .where(eq(schema.records.versionId, version.id,),) - const fileRefs = new Map(); + const fileRefs = new Map() for (const rec of allRecords) { - const data = rec.data as Record; - for (const [field, val] of Object.entries(data)) { - if (val && typeof val === "object" && "$file" in (val as any)) { - const hash = ((val as any).$file as string).replace("sha256:", ""); - if (!fileRefs.has(hash)) fileRefs.set(hash, []); - fileRefs.get(hash)!.push({ recordId: rec.recordId, type: rec.type, field }); + const data = rec.data as Record + for (const [field, val,] of Object.entries(data,)) { + if (val && typeof val === 'object' && '$file' in (val as any)) { + const hash = ((val as any).$file as string).replace('sha256:', '',) + if (!fileRefs.has(hash,)) fileRefs.set(hash, [],) + fileRefs.get(hash,)!.push({ recordId: rec.recordId, type: rec.type, field, },) } } } - return c.json(fileRows.map((f) => ({ + return c.json(fileRows.map((f,) => ({ ...f, - references: fileRefs.get(f.hash) ?? [], - }))); + references: fileRefs.get(f.hash,) ?? [], + })),) } // Get manifest for a version -export async function manifest(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const n = c.req.param("n")!; - const collection = await resolveCollection(owner, slug); - if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); - - const [version] = await db +export async function manifest(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const n = c.req.param('n',)! + const collection = await resolveCollection(owner, slug,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + + const [version,] = await db .select() - .from(schema.versions) + .from(schema.versions,) .where( - and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, parseInt(n, 10))), + and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), ) - .limit(1); + .limit(1,) - if (!version) return c.json({ error: "Version not found", statusCode: 404 }, 404); + if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) const recordIds = await db - .select({ id: schema.records.recordId, type: schema.records.type }) - .from(schema.records) - .where(eq(schema.records.versionId, version.id)); + .select({ id: schema.records.recordId, type: schema.records.type, },) + .from(schema.records,) + .where(eq(schema.records.versionId, version.id,),) const fileHashes = await db - .select({ hash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where(eq(schema.versionFiles.versionId, version.id)); + .select({ hash: schema.versionFiles.fileHash, },) + .from(schema.versionFiles,) + .where(eq(schema.versionFiles.versionId, version.id,),) - const schemaEntries = await loadVersionSchemas(version.id); + const schemaEntries = await loadVersionSchemas(version.id,) return c.json({ version: version.number, semver: version.semver, hash: version.hash, - schemas: Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schemaHash])), + schemas: Object.fromEntries(schemaEntries.map((e,) => [e.slug, e.schemaHash,]),), records: recordIds, - files: fileHashes.map((f) => f.hash), - }); + files: fileHashes.map((f,) => f.hash), + },) } // Push a new version -export async function push(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const body = await c.req.json() as { - base_version: number | null; - name?: string; - description?: string; - message?: string; - readme?: string; - app_id?: string; - actor_id?: string; - schemas?: Record; - changes: { - added?: { id: string; type: string; data: unknown; private?: boolean }[]; - updated?: { id: string; type: string; data: unknown; private?: boolean }[]; - removed?: string[]; - }; - }; - - const collection = await resolveCollection(owner, slug); - if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); - - // Get latest version - const [latest] = await db - .select() - .from(schema.versions) - .where(eq(schema.versions.collectionId, collection.id)) - .orderBy(sql`${schema.versions.number} desc`) - .limit(1); +export async function push(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const body = await c.req.json() as { + base_version: number | null + name?: string + description?: string + message?: string + readme?: string + app_id?: string + actor_id?: string + schemas?: Record + changes: { + added?: { id: string; type: string; data: unknown; private?: boolean }[] + updated?: { id: string; type: string; data: unknown; private?: boolean }[] + removed?: string[] + } + } - const currentNumber = latest?.number ?? 0; + const collection = await resolveCollection(owner, slug,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) - // Optimistic lock - if (body.base_version !== null && body.base_version !== currentNumber) { - return c.json({ - error: "Version conflict", - currentVersion: currentNumber, - statusCode: 409, - }, 409); - } + // Get latest version + const [latest,] = await db + .select() + .from(schema.versions,) + .where(eq(schema.versions.collectionId, collection.id,),) + .orderBy(sql`${schema.versions.number} desc`,) + .limit(1,) - // Build the full record set for this version - let existingRecords: { recordId: string; type: string; data: unknown; private: boolean }[] = []; - if (latest) { - existingRecords = await db - .select({ - recordId: schema.records.recordId, - type: schema.records.type, - data: schema.records.data, - private: schema.records.private, - }) - .from(schema.records) - .where(eq(schema.records.versionId, latest.id)); - } + const currentNumber = latest?.number ?? 0 - // Apply changes - const recordMap = new Map(existingRecords.map((r) => [r.recordId, r])); + // Optimistic lock + if (body.base_version !== null && body.base_version !== currentNumber) { + return c.json({ + error: 'Version conflict', + currentVersion: currentNumber, + statusCode: 409, + }, 409,) + } - for (const rec of body.changes.added ?? []) { - recordMap.set(rec.id, { recordId: rec.id, type: rec.type, data: rec.data, private: rec.private ?? false }); - } - for (const rec of body.changes.updated ?? []) { - recordMap.set(rec.id, { recordId: rec.id, type: rec.type, data: rec.data, private: rec.private ?? false }); - } - for (const id of body.changes.removed ?? []) { - recordMap.delete(id); - } + // Build the full record set for this version + let existingRecords: { recordId: string; type: string; data: unknown; private: boolean }[] = [] + if (latest) { + existingRecords = await db + .select({ + recordId: schema.records.recordId, + type: schema.records.type, + data: schema.records.data, + private: schema.records.private, + },) + .from(schema.records,) + .where(eq(schema.records.versionId, latest.id,),) + } - const newRecords = Array.from(recordMap.values()); + // Apply changes + const recordMap = new Map(existingRecords.map((r,) => [r.recordId, r,]),) - // --- Resolve schemas --- - let prevSchemaEntries: SchemaEntry[] = []; - if (latest) { - prevSchemaEntries = await loadVersionSchemas(latest.id); - } + for (const rec of body.changes.added ?? []) { + recordMap.set(rec.id, { recordId: rec.id, type: rec.type, data: rec.data, private: rec.private ?? false, },) + } + for (const rec of body.changes.updated ?? []) { + recordMap.set(rec.id, { recordId: rec.id, type: rec.type, data: rec.data, private: rec.private ?? false, },) + } + for (const id of body.changes.removed ?? []) { + recordMap.delete(id,) + } - // Determine the schema set for this version - let schemasInput: Record; - if (body.schemas && Object.keys(body.schemas).length > 0) { - schemasInput = body.schemas; - } else if (prevSchemaEntries.length > 0) { - // Carry forward previous schemas - schemasInput = Object.fromEntries(prevSchemaEntries.map((e) => [e.slug, e.schema])); - } else { - return c.json({ - error: "Schemas required", - message: "First version must include a `schemas` map with at least one type definition.", - statusCode: 422, - }, 422); - } + const newRecords = Array.from(recordMap.values(),) - // Ensure every record type has a schema - const recordTypes = new Set(newRecords.map((r) => r.type)); - const missingSchemas = [...recordTypes].filter((t) => !(t in schemasInput)); - if (missingSchemas.length > 0) { - return c.json({ - error: "Missing schemas for record types", - types: missingSchemas, - message: `Every record type must have a corresponding schema. Missing: ${missingSchemas.join(", ")}`, - statusCode: 422, - }, 422); - } + // --- Resolve schemas --- + let prevSchemaEntries: SchemaEntry[] = [] + if (latest) { + prevSchemaEntries = await loadVersionSchemas(latest.id,) + } - // Hash and upsert each schema into the global schemas table - const newSchemaSet: { slug: string; schemaId: string; schemaHash: string; schema: Record }[] = []; - for (const [typeSlug, typeSchema] of Object.entries(schemasInput)) { - const hash = hashSchema(typeSchema); - - const [existing] = await db - .select({ id: schema.schemas.id }) - .from(schema.schemas) - .where(eq(schema.schemas.schemaHash, hash)) - .limit(1); - - let schemaId: string; - if (existing) { - schemaId = existing.id; - } else { - const [inserted] = await db - .insert(schema.schemas) - .values({ schema: typeSchema as any, schemaHash: hash }) - .returning({ id: schema.schemas.id }); - schemaId = inserted!.id; - } + // Determine the schema set for this version + let schemasInput: Record + if (body.schemas && Object.keys(body.schemas,).length > 0) { + schemasInput = body.schemas + } else if (prevSchemaEntries.length > 0) { + // Carry forward previous schemas + schemasInput = Object.fromEntries(prevSchemaEntries.map((e,) => [e.slug, e.schema,]),) + } else { + return c.json({ + error: 'Schemas required', + message: 'First version must include a `schemas` map with at least one type definition.', + statusCode: 422, + }, 422,) + } - newSchemaSet.push({ slug: typeSlug, schemaId, schemaHash: hash, schema: typeSchema as Record }); - } + // Ensure every record type has a schema + const recordTypes = new Set(newRecords.map((r,) => r.type),) + const missingSchemas = [...recordTypes,].filter((t,) => !(t in schemasInput)) + if (missingSchemas.length > 0) { + return c.json({ + error: 'Missing schemas for record types', + types: missingSchemas, + message: `Every record type must have a corresponding schema. Missing: ${missingSchemas.join(', ',)}`, + statusCode: 422, + }, 422,) + } - // Validate records against their type's schema - const validationErrors: { recordId: string; type: string; errors: string[] }[] = []; - const validators = new Map>(); - for (const entry of newSchemaSet) { - validators.set(entry.slug, ajv.compile(entry.schema as object)); - } + // Hash and upsert each schema into the global schemas table + const newSchemaSet: { slug: string; schemaId: string; schemaHash: string; schema: Record }[] = [] + for (const [typeSlug, typeSchema,] of Object.entries(schemasInput,)) { + const hash = hashSchema(typeSchema,) - for (const rec of newRecords) { - const validate = validators.get(rec.type); - if (!validate) { - validationErrors.push({ - recordId: rec.recordId, - type: rec.type, - errors: [`No schema defined for record type "${rec.type}"`], - }); - continue; - } - if (!validate(rec.data)) { - validationErrors.push({ - recordId: rec.recordId, - type: rec.type, - errors: (validate.errors ?? []).map( - (e) => `${e.instancePath || "/"} ${e.message ?? "validation failed"}`, - ), - }); - } - } + const [existing,] = await db + .select({ id: schema.schemas.id, },) + .from(schema.schemas,) + .where(eq(schema.schemas.schemaHash, hash,),) + .limit(1,) - if (validationErrors.length > 0) { - return c.json({ - error: "Schema validation failed", - validationErrors, - statusCode: 422, - }, 422); + let schemaId: string + if (existing) { + schemaId = existing.id + } else { + const [inserted,] = await db + .insert(schema.schemas,) + .values({ schema: typeSchema as any, schemaHash: hash, },) + .returning({ id: schema.schemas.id, },) + schemaId = inserted!.id } - // Determine if schema set changed - const prevSchemaMap = new Map(prevSchemaEntries.map((e) => [e.slug, e.schemaHash])); - const newSchemaMap = new Map(newSchemaSet.map((e) => [e.slug, e.schemaHash])); - let schemaChanged = prevSchemaMap.size !== newSchemaMap.size; - if (!schemaChanged) { - for (const [s, hash] of newSchemaMap) { - if (prevSchemaMap.get(s) !== hash) { - schemaChanged = true; - break; - } - } - } + newSchemaSet.push({ slug: typeSlug, schemaId, schemaHash: hash, schema: typeSchema as Record, },) + } + + // Validate records against their type's schema + const validationErrors: { recordId: string; type: string; errors: string[] }[] = [] + const validators = new Map>() + for (const entry of newSchemaSet) { + validators.set(entry.slug, ajv.compile(entry.schema as object,),) + } - const recordsChanged = - (body.changes.added?.length ?? 0) > 0 || - (body.changes.updated?.length ?? 0) > 0 || - (body.changes.removed?.length ?? 0) > 0; - - // Get file hashes from existing version + any new references - let existingFileHashes: string[] = []; - if (latest) { - const vf = await db - .select({ hash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where(eq(schema.versionFiles.versionId, latest.id)); - existingFileHashes = vf.map((f) => f.hash); + for (const rec of newRecords) { + const validate = validators.get(rec.type,) + if (!validate) { + validationErrors.push({ + recordId: rec.recordId, + type: rec.type, + errors: [`No schema defined for record type "${rec.type}"`,], + },) + continue } + if (!validate(rec.data,)) { + validationErrors.push({ + recordId: rec.recordId, + type: rec.type, + errors: (validate.errors ?? []).map( + (e,) => `${e.instancePath || '/'} ${e.message ?? 'validation failed'}`, + ), + },) + } + } - // Scan new records for $file references - const referencedHashes = new Set(existingFileHashes); - for (const rec of newRecords) { - const data = rec.data as Record; - for (const val of Object.values(data)) { - if ( - typeof val === "object" && - val !== null && - "$file" in val && - typeof (val as { $file: string }).$file === "string" - ) { - const hash = (val as { $file: string }).$file.replace("sha256:", ""); - referencedHashes.add(hash); - } + if (validationErrors.length > 0) { + return c.json({ + error: 'Schema validation failed', + validationErrors, + statusCode: 422, + }, 422,) + } + + // Determine if schema set changed + const prevSchemaMap = new Map(prevSchemaEntries.map((e,) => [e.slug, e.schemaHash,]),) + const newSchemaMap = new Map(newSchemaSet.map((e,) => [e.slug, e.schemaHash,]),) + let schemaChanged = prevSchemaMap.size !== newSchemaMap.size + if (!schemaChanged) { + for (const [s, hash,] of newSchemaMap) { + if (prevSchemaMap.get(s,) !== hash) { + schemaChanged = true + break } } + } - // Check all referenced files exist - const allFileHashes = Array.from(referencedHashes); - if (allFileHashes.length > 0) { - const existingFiles = await db - .select({ hash: schema.files.hash }) - .from(schema.files) - .where(inArray(schema.files.hash, allFileHashes)); - const existingSet = new Set(existingFiles.map((f) => f.hash)); - const filesNeeded = allFileHashes.filter((h) => !existingSet.has(h)); - - if (filesNeeded.length > 0) { - return c.json({ - error: "Missing files", - filesNeeded: filesNeeded.map((h) => `sha256:${h}`), - statusCode: 422, - }, 422); + const recordsChanged = (body.changes.added?.length ?? 0) > 0 + || (body.changes.updated?.length ?? 0) > 0 + || (body.changes.removed?.length ?? 0) > 0 + + // Get file hashes from existing version + any new references + let existingFileHashes: string[] = [] + if (latest) { + const vf = await db + .select({ hash: schema.versionFiles.fileHash, },) + .from(schema.versionFiles,) + .where(eq(schema.versionFiles.versionId, latest.id,),) + existingFileHashes = vf.map((f,) => f.hash) + } + + // Scan new records for $file references + const referencedHashes = new Set(existingFileHashes,) + for (const rec of newRecords) { + const data = rec.data as Record + for (const val of Object.values(data,)) { + if ( + typeof val === 'object' + && val !== null + && '$file' in val + && typeof (val as { $file: string }).$file === 'string' + ) { + const hash = (val as { $file: string }).$file.replace('sha256:', '',) + referencedHashes.add(hash,) } } + } - // Resolve readme (carry forward from base version if not provided) - const readmeValue = body.readme !== undefined ? body.readme : (latest?.readme ?? null); - - // Compute hashes and semver - const schemaSetForHash = newSchemaSet.map((e) => ({ slug: e.slug, schemaHash: e.schemaHash })); - const versionHash = computeVersionHash(schemaSetForHash, newRecords, allFileHashes, readmeValue); - - const schemaEntriesForPublicHash: SchemaEntry[] = newSchemaSet.map((e) => ({ - slug: e.slug, - schemaId: e.schemaId, - schema: e.schema, - schemaHash: e.schemaHash, - })); - const publicHash = computePublicHash(schemaEntriesForPublicHash, newRecords, allFileHashes, readmeValue); - - const semver = deriveSemver(latest?.semver ?? null, schemaChanged, recordsChanged); - const newNumber = currentNumber + 1; - - // Check for duplicate hash - const [existingHash] = await db - .select({ number: schema.versions.number }) - .from(schema.versions) - .where( - and( - eq(schema.versions.collectionId, collection.id), - eq(schema.versions.hash, versionHash), - ), - ) - .limit(1); - if (existingHash) { + // Check all referenced files exist + const allFileHashes = Array.from(referencedHashes,) + if (allFileHashes.length > 0) { + const existingFiles = await db + .select({ hash: schema.files.hash, },) + .from(schema.files,) + .where(inArray(schema.files.hash, allFileHashes,),) + const existingSet = new Set(existingFiles.map((f,) => f.hash),) + const filesNeeded = allFileHashes.filter((h,) => !existingSet.has(h,)) + + if (filesNeeded.length > 0) { return c.json({ - error: "No changes detected", - message: `Version ${existingHash.number} already has identical content (hash: ${versionHash.slice(0, 12)}...)`, - existingVersion: existingHash.number, - }, 409); + error: 'Missing files', + filesNeeded: filesNeeded.map((h,) => `sha256:${h}`), + statusCode: 422, + }, 422,) } + } - // Compute total bytes - let totalBytes = 0; - for (const rec of newRecords) { - totalBytes += Buffer.byteLength(JSON.stringify(rec.data), "utf-8"); - } - if (allFileHashes.length > 0) { - const [fileSizeSum] = await db - .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) - .from(schema.files) - .where(inArray(schema.files.hash, allFileHashes)); - totalBytes += Number(fileSizeSum?.total ?? 0); - } + // Resolve readme (carry forward from base version if not provided) + const readmeValue = body.readme !== undefined ? body.readme : (latest?.readme ?? null) - // Insert version - const [version] = await db - .insert(schema.versions) - .values({ - collectionId: collection.id, - number: newNumber, - semver, - hash: versionHash, - publicHash, - baseNumber: body.base_version, - message: body.message ?? null, - readme: readmeValue, - pushedBy: c.get("accountId") ?? null, - appId: body.app_id ?? null, - actorId: body.actor_id ?? null, - recordCount: newRecords.length, - fileCount: allFileHashes.length, - totalBytes, - }) - .returning(); - - // Insert records (in batches) - if (newRecords.length > 0) { - const RECORD_BATCH = 1000; - for (let i = 0; i < newRecords.length; i += RECORD_BATCH) { - const batch = newRecords.slice(i, i + RECORD_BATCH); - await db.insert(schema.records).values( - batch.map((r) => ({ - versionId: version!.id, - recordId: r.recordId, - type: r.type, - data: r.data as any, - private: r.private, - })), - ); - } - } + // Compute hashes and semver + const schemaSetForHash = newSchemaSet.map((e,) => ({ slug: e.slug, schemaHash: e.schemaHash, })) + const versionHash = computeVersionHash(schemaSetForHash, newRecords, allFileHashes, readmeValue,) + + const schemaEntriesForPublicHash: SchemaEntry[] = newSchemaSet.map((e,) => ({ + slug: e.slug, + schemaId: e.schemaId, + schema: e.schema, + schemaHash: e.schemaHash, + })) + const publicHash = computePublicHash(schemaEntriesForPublicHash, newRecords, allFileHashes, readmeValue,) + + const semver = deriveSemver(latest?.semver ?? null, schemaChanged, recordsChanged,) + const newNumber = currentNumber + 1 - // Insert version_files - if (allFileHashes.length > 0) { - await db.insert(schema.versionFiles).values( - allFileHashes.map((hash) => ({ + // Check for duplicate hash + const [existingHash,] = await db + .select({ number: schema.versions.number, },) + .from(schema.versions,) + .where( + and( + eq(schema.versions.collectionId, collection.id,), + eq(schema.versions.hash, versionHash,), + ), + ) + .limit(1,) + if (existingHash) { + return c.json({ + error: 'No changes detected', + message: `Version ${existingHash.number} already has identical content (hash: ${versionHash.slice(0, 12,)}...)`, + existingVersion: existingHash.number, + }, 409,) + } + + // Compute total bytes + let totalBytes = 0 + for (const rec of newRecords) { + totalBytes += Buffer.byteLength(JSON.stringify(rec.data,), 'utf-8',) + } + if (allFileHashes.length > 0) { + const [fileSizeSum,] = await db + .select({ total: sql`coalesce(sum(${schema.files.size}), 0)`, },) + .from(schema.files,) + .where(inArray(schema.files.hash, allFileHashes,),) + totalBytes += Number(fileSizeSum?.total ?? 0,) + } + + // Insert version + const [version,] = await db + .insert(schema.versions,) + .values({ + collectionId: collection.id, + number: newNumber, + semver, + hash: versionHash, + publicHash, + baseNumber: body.base_version, + message: body.message ?? null, + readme: readmeValue, + pushedBy: c.get('accountId',) ?? null, + appId: body.app_id ?? null, + actorId: body.actor_id ?? null, + recordCount: newRecords.length, + fileCount: allFileHashes.length, + totalBytes, + },) + .returning() + + // Insert records (in batches) + if (newRecords.length > 0) { + const RECORD_BATCH = 1000 + for (let i = 0; i < newRecords.length; i += RECORD_BATCH) { + const batch = newRecords.slice(i, i + RECORD_BATCH,) + await db.insert(schema.records,).values( + batch.map((r,) => ({ versionId: version!.id, - fileHash: hash, + recordId: r.recordId, + type: r.type, + data: r.data as any, + private: r.private, })), - ); + ) } + } - // Insert version_schemas - await db.insert(schema.versionSchemas).values( - newSchemaSet.map((entry) => ({ + // Insert version_files + if (allFileHashes.length > 0) { + await db.insert(schema.versionFiles,).values( + allFileHashes.map((hash,) => ({ versionId: version!.id, - slug: entry.slug, - schemaId: entry.schemaId, + fileHash: hash, })), - ); + ) + } - // Update collection timestamp + optional name/description - const collectionUpdates: Record = { updatedAt: new Date() }; - if (body.name) collectionUpdates.name = body.name; - if (body.description !== undefined) collectionUpdates.description = body.description; - await db - .update(schema.collections) - .set(collectionUpdates) - .where(eq(schema.collections.id, collection.id)); + // Insert version_schemas + await db.insert(schema.versionSchemas,).values( + newSchemaSet.map((entry,) => ({ + versionId: version!.id, + slug: entry.slug, + schemaId: entry.schemaId, + })), + ) + + // Update collection timestamp + optional name/description + const collectionUpdates: Record = { updatedAt: new Date(), } + if (body.name) collectionUpdates.name = body.name + if (body.description !== undefined) collectionUpdates.description = body.description + await db + .update(schema.collections,) + .set(collectionUpdates,) + .where(eq(schema.collections.id, collection.id,),) - return c.json({ - version: newNumber, - semver, - hash: versionHash, - recordCount: newRecords.length, - fileCount: allFileHashes.length, - }, 201); + return c.json({ + version: newNumber, + semver, + hash: versionHash, + recordCount: newRecords.length, + fileCount: allFileHashes.length, + }, 201,) } // Diff between versions -export async function diff(c: Context) { - const owner = c.req.param("owner")!; - const slug = c.req.param("slug")!; - const n = c.req.param("n")!; - const from = c.req.query("from"); +export async function diff(c: Context,) { + const owner = c.req.param('owner',)! + const slug = c.req.param('slug',)! + const n = c.req.param('n',)! + const from = c.req.query('from',) - const collection = await resolveCollection(owner, slug); - if (!collection) return c.json({ error: "Collection not found", statusCode: 404 }, 404); + const collection = await resolveCollection(owner, slug,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) - const targetNum = parseInt(n, 10); - const fromNum = from ? parseInt(from, 10) : targetNum - 1; + const targetNum = parseInt(n, 10,) + const fromNum = from ? parseInt(from, 10,) : targetNum - 1 - const [targetVersion] = await db + const [targetVersion,] = await db .select() - .from(schema.versions) - .where(and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, targetNum))) - .limit(1); + .from(schema.versions,) + .where(and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, targetNum,),),) + .limit(1,) if (!targetVersion) { - return c.json({ error: "Version not found", statusCode: 404 }, 404); + return c.json({ error: 'Version not found', statusCode: 404, }, 404,) } const targetRecords = await db .select() - .from(schema.records) - .where(eq(schema.records.versionId, targetVersion.id)); + .from(schema.records,) + .where(eq(schema.records.versionId, targetVersion.id,),) - let fromVersion: typeof targetVersion | null = null; - let fromRecords: typeof targetRecords = []; + let fromVersion: typeof targetVersion | null = null + let fromRecords: typeof targetRecords = [] if (fromNum > 0) { - const [fv] = await db + const [fv,] = await db .select() - .from(schema.versions) - .where(and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, fromNum))) - .limit(1); + .from(schema.versions,) + .where(and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, fromNum,),),) + .limit(1,) if (fv) { - fromVersion = fv; + fromVersion = fv fromRecords = await db .select() - .from(schema.records) - .where(eq(schema.records.versionId, fv.id)); + .from(schema.records,) + .where(eq(schema.records.versionId, fv.id,),) } } - const fromMap = new Map(fromRecords.map((r) => [r.recordId, r])); - const targetMap = new Map(targetRecords.map((r) => [r.recordId, r])); + const fromMap = new Map(fromRecords.map((r,) => [r.recordId, r,]),) + const targetMap = new Map(targetRecords.map((r,) => [r.recordId, r,]),) - const added = targetRecords.filter((r) => !fromMap.has(r.recordId)); - const removed = fromRecords.filter((r) => !targetMap.has(r.recordId)); - const updated = targetRecords.filter((r) => { - const prev = fromMap.get(r.recordId); - return prev && JSON.stringify(prev.data) !== JSON.stringify(r.data); - }); + const added = targetRecords.filter((r,) => !fromMap.has(r.recordId,)) + const removed = fromRecords.filter((r,) => !targetMap.has(r.recordId,)) + const updated = targetRecords.filter((r,) => { + const prev = fromMap.get(r.recordId,) + return prev && JSON.stringify(prev.data,) !== JSON.stringify(r.data,) + },) // Compare schema sets - const targetSchemas = await loadVersionSchemas(targetVersion.id); - const fromSchemas = fromVersion ? await loadVersionSchemas(fromVersion.id) : []; - const targetSchemaMap = new Map(targetSchemas.map((e) => [e.slug, e.schemaHash])); - const fromSchemaMap = new Map(fromSchemas.map((e) => [e.slug, e.schemaHash])); - let schemaChanged = targetSchemaMap.size !== fromSchemaMap.size; + const targetSchemas = await loadVersionSchemas(targetVersion.id,) + const fromSchemas = fromVersion ? await loadVersionSchemas(fromVersion.id,) : [] + const targetSchemaMap = new Map(targetSchemas.map((e,) => [e.slug, e.schemaHash,]),) + const fromSchemaMap = new Map(fromSchemas.map((e,) => [e.slug, e.schemaHash,]),) + let schemaChanged = targetSchemaMap.size !== fromSchemaMap.size if (!schemaChanged) { - for (const [s, hash] of targetSchemaMap) { - if (fromSchemaMap.get(s) !== hash) { - schemaChanged = true; - break; + for (const [s, hash,] of targetSchemaMap) { + if (fromSchemaMap.get(s,) !== hash) { + schemaChanged = true + break } } } - const readmeChanged = (targetVersion.readme ?? null) !== (fromVersion?.readme ?? null); + const readmeChanged = (targetVersion.readme ?? null) !== (fromVersion?.readme ?? null) // Compare file sets const targetFiles = await db - .select({ hash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where(eq(schema.versionFiles.versionId, targetVersion.id)); - const fromFiles = fromVersion ? await db - .select({ hash: schema.versionFiles.fileHash }) - .from(schema.versionFiles) - .where(eq(schema.versionFiles.versionId, fromVersion.id)) : []; - const targetFileSet = new Set(targetFiles.map((f) => f.hash)); - const fromFileSet = new Set(fromFiles.map((f) => f.hash)); - const filesAdded = targetFiles.filter((f) => !fromFileSet.has(f.hash)).map((f) => f.hash); - const filesRemoved = fromFiles.filter((f) => !targetFileSet.has(f.hash)).map((f) => f.hash); + .select({ hash: schema.versionFiles.fileHash, },) + .from(schema.versionFiles,) + .where(eq(schema.versionFiles.versionId, targetVersion.id,),) + const fromFiles = fromVersion + ? await db + .select({ hash: schema.versionFiles.fileHash, },) + .from(schema.versionFiles,) + .where(eq(schema.versionFiles.versionId, fromVersion.id,),) + : [] + const targetFileSet = new Set(targetFiles.map((f,) => f.hash),) + const fromFileSet = new Set(fromFiles.map((f,) => f.hash),) + const filesAdded = targetFiles.filter((f,) => !fromFileSet.has(f.hash,)).map((f,) => f.hash) + const filesRemoved = fromFiles.filter((f,) => !targetFileSet.has(f.hash,)).map((f,) => f.hash) return c.json({ from: fromNum, to: targetNum, - added: added.map((r) => ({ id: r.recordId, type: r.type, data: r.data })), - updated: updated.map((r) => ({ id: r.recordId, type: r.type, data: r.data })), - removed: removed.map((r) => r.recordId), + added: added.map((r,) => ({ id: r.recordId, type: r.type, data: r.data, })), + updated: updated.map((r,) => ({ id: r.recordId, type: r.type, data: r.data, })), + removed: removed.map((r,) => r.recordId), meta: { schemaChanged, readmeChanged, - readmeFrom: readmeChanged ? (fromVersion?.readme?.slice(0, 100) ?? null) : undefined, - readmeTo: readmeChanged ? (targetVersion.readme?.slice(0, 100) ?? null) : undefined, + readmeFrom: readmeChanged ? (fromVersion?.readme?.slice(0, 100,) ?? null) : undefined, + readmeTo: readmeChanged ? (targetVersion.readme?.slice(0, 100,) ?? null) : undefined, filesAdded: filesAdded.length, filesRemoved: filesRemoved.length, }, - }); + },) } -async function resolveCollection(owner: string, slug: string) { - const [result] = await db +async function resolveCollection(owner: string, slug: string,) { + const [result,] = await db .select({ id: schema.collections.id, accountId: schema.collections.accountId, slug: schema.collections.slug, - }) - .from(schema.collections) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) - .limit(1); - return result ?? null; + },) + .from(schema.collections,) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) + .limit(1,) + return result ?? null } async function getCollectionArkInfo( collectionId: string, ): Promise<{ shoulder: string; arkId: string; naan: string } | null> { - const [row] = await db + const [row,] = await db .select({ shoulder: schema.arkShoulders.shoulder, arkId: schema.arkCollections.arkId, naan: schema.accounts.arkNaan, - }) - .from(schema.arkCollections) - .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) - .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id)) - .where(and(eq(schema.arkCollections.collectionId, collectionId), eq(schema.arkCollections.enabled, true))) - .limit(1); - if (!row) return null; - return { shoulder: row.shoulder, arkId: row.arkId, naan: row.naan ?? DEFAULT_NAAN }; + },) + .from(schema.arkCollections,) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id,),) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) + .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id,),) + .where(and(eq(schema.arkCollections.collectionId, collectionId,), eq(schema.arkCollections.enabled, true,),),) + .limit(1,) + if (!row) return null + return { shoulder: row.shoulder, arkId: row.arkId, naan: row.naan ?? DEFAULT_NAAN, } } - diff --git a/src/components/ApiPlayground.tsx b/src/components/ApiPlayground.tsx index 7e8813e..d072206 100644 --- a/src/components/ApiPlayground.tsx +++ b/src/components/ApiPlayground.tsx @@ -1,200 +1,202 @@ -import { useState, useCallback } from "react"; +import { useCallback, useState, } from 'react' interface Collection { - id: string; - slug: string; + id: string + slug: string } interface ApiPlaygroundProps { - slug: string; - collections: Collection[]; + slug: string + collections: Collection[] } interface ResponseState { - status: number; - statusText: string; - time: number; - body: string; + status: number + statusText: string + time: number + body: string } interface Endpoint { - label: string; - method: string; - path: string; - body: string; - description: string; + label: string + method: string + path: string + body: string + description: string } -function getEndpoints(slug: string, collectionSlug: string): Endpoint[] { +function getEndpoints(slug: string, collectionSlug: string,): Endpoint[] { return [ { - label: "List collections", - method: "GET", + label: 'List collections', + method: 'GET', path: `/api/accounts/${slug}/collections`, - body: "", - description: "Returns all collections for this account.", + body: '', + description: 'Returns all collections for this account.', }, { - label: "Get account profile", - method: "GET", + label: 'Get account profile', + method: 'GET', path: `/api/accounts/${slug}`, - body: "", - description: "Returns public profile information.", + body: '', + description: 'Returns public profile information.', }, ...(collectionSlug ? [ - { - label: "Get collection", - method: "GET", - path: `/api/collections/${slug}/${collectionSlug}`, - body: "", - description: "Returns collection metadata and latest version info.", - }, - { - label: "List versions", - method: "GET", - path: `/api/collections/${slug}/${collectionSlug}/versions`, - body: "", - description: "Returns all versions for this collection.", - }, - { - label: "Get latest version", - method: "GET", - path: `/api/collections/${slug}/${collectionSlug}/versions/latest`, - body: "", - description: "Returns the latest version with records and files.", - }, - { - label: "List files", - method: "GET", - path: `/api/collections/${slug}/${collectionSlug}/files`, - body: "", - description: "Returns all files in the latest version.", - }, - ] + { + label: 'Get collection', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}`, + body: '', + description: 'Returns collection metadata and latest version info.', + }, + { + label: 'List versions', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}/versions`, + body: '', + description: 'Returns all versions for this collection.', + }, + { + label: 'Get latest version', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}/versions/latest`, + body: '', + description: 'Returns the latest version with records and files.', + }, + { + label: 'List files', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}/files`, + body: '', + description: 'Returns all files in the latest version.', + }, + ] : []), - ]; + ] } +export function ApiPlayground({ slug, collections, }: ApiPlaygroundProps,) { + const [selectedCollection, setSelectedCollection,] = useState(collections[0]?.slug ?? '',) + const [selectedEndpoint, setSelectedEndpoint,] = useState(0,) + const [response, setResponse,] = useState(null,) + const [loading, setLoading,] = useState(false,) + const [copied, setCopied,] = useState(false,) + const [token, setToken,] = useState('',) - -export function ApiPlayground({ slug, collections }: ApiPlaygroundProps) { - const [selectedCollection, setSelectedCollection] = useState(collections[0]?.slug ?? ""); - const [selectedEndpoint, setSelectedEndpoint] = useState(0); - const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(false); - const [copied, setCopied] = useState(false); - const [token, setToken] = useState(""); - - const endpoints = getEndpoints(slug, selectedCollection); - const current = endpoints[selectedEndpoint] ?? endpoints[0]; + const endpoints = getEndpoints(slug, selectedCollection,) + const current = endpoints[selectedEndpoint] ?? endpoints[0] const sendRequest = useCallback(async () => { - if (!current) return; - setLoading(true); - setResponse(null); + if (!current) return + setLoading(true,) + setResponse(null,) - const start = performance.now(); + const start = performance.now() try { - const headers: Record = { "Content-Type": "application/json" }; + const headers: Record = { 'Content-Type': 'application/json', } if (token.trim()) { - headers["Authorization"] = `Bearer ${token.trim()}`; + headers['Authorization'] = `Bearer ${token.trim()}` } const opts: RequestInit = { method: current.method, headers, - credentials: token.trim() ? "omit" : "include", - }; - if (current.body && current.method !== "GET") { - opts.body = current.body; + credentials: token.trim() ? 'omit' : 'include', + } + if (current.body && current.method !== 'GET') { + opts.body = current.body } - const res = await fetch(current.path, opts); - const elapsed = Math.round(performance.now() - start); - let body: string; - const contentType = res.headers.get("content-type") ?? ""; - if (contentType.includes("json")) { - const json = await res.json(); - body = JSON.stringify(json, null, 2); + const res = await fetch(current.path, opts,) + const elapsed = Math.round(performance.now() - start,) + let body: string + const contentType = res.headers.get('content-type',) ?? '' + if (contentType.includes('json',)) { + const json = await res.json() + body = JSON.stringify(json, null, 2,) } else { - body = await res.text(); + body = await res.text() } - setResponse({ status: res.status, statusText: res.statusText, time: elapsed, body }); + setResponse({ status: res.status, statusText: res.statusText, time: elapsed, body, },) } catch (err: any) { - setResponse({ status: 0, statusText: "Network Error", time: 0, body: err.message }); + setResponse({ status: 0, statusText: 'Network Error', time: 0, body: err.message, },) } finally { - setLoading(false); + setLoading(false,) } - }, [current, token]); + }, [current, token,],) const copyAsCurl = useCallback(() => { - if (!current) return; - const keyValue = token.trim() || ""; - let cmd = `curl -X ${current.method} '${window.location.origin}${current.path}'`; - cmd += ` \\\n -H 'Authorization: Bearer ${keyValue}'`; - if (current.body && current.method !== "GET") { - cmd += ` \\\n -H 'Content-Type: application/json'`; - cmd += ` \\\n -d '${current.body}'`; + if (!current) return + const keyValue = token.trim() || '' + let cmd = `curl -X ${current.method} '${window.location.origin}${current.path}'` + cmd += ` \\\n -H 'Authorization: Bearer ${keyValue}'` + if (current.body && current.method !== 'GET') { + cmd += ` \\\n -H 'Content-Type: application/json'` + cmd += ` \\\n -d '${current.body}'` } - navigator.clipboard.writeText(cmd); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [current, token]); + navigator.clipboard.writeText(cmd,) + setCopied(true,) + setTimeout(() => setCopied(false,), 2000,) + }, [current, token,],) return ( -
+
{/* Controls bar */} -
+
{collections.length > 0 && ( -
- +
+
)} -
- +
+ setToken(e.target.value)} - placeholder="Paste key to test it (optional)" - className="text-xs bg-parchment border border-rule px-2 py-1 w-52 font-mono focus:outline-none focus:border-ink" + onChange={(e,) => setToken(e.target.value,)} + placeholder='Paste key to test it (optional)' + className='text-xs bg-parchment border border-rule px-2 py-1 w-52 font-mono focus:outline-none focus:border-ink' />
-
+
{/* Left column: endpoint list */} -
-

Endpoints

-
- {endpoints.map((ep, i) => ( +
+

Endpoints

+
+ {endpoints.map((ep, i,) => (
{collections.length === 0 && ( -

+

No collections yet. Create one to see collection endpoints.

)}
{/* Right column: request + response */} -
+
{current && ( <> {/* Request display */} -
-
- +
+
+ {current.method} - {current.path} + {current.path}
-

{current.description}

+

{current.description}

{/* Action bar */} -
+
- - {token.trim() ? "Using API key" : "Using your session"} + + {token.trim() ? 'Using API key' : 'Using your session'}
@@ -254,26 +262,31 @@ export function ApiPlayground({ slug, collections }: ApiPlaygroundProps) { {/* Response */} {response && (
-
= 200 && response.status < 300 ? "bg-green-50 text-green-800" : - response.status >= 400 ? "bg-red-50 text-red-800" : "bg-parchment-dark" - }`}> - {response.status} {response.statusText} - {response.time}ms +
= 200 && response.status < 300 + ? 'bg-green-50 text-green-800' + : response.status >= 400 + ? 'bg-red-50 text-red-800' + : 'bg-parchment-dark' + }`} + > + {response.status} {response.statusText} + {response.time}ms
-
+              
                 {response.body}
               
)} {!response && !loading && ( -
+
Select an endpoint and hit Send to see the response.
)}
- ); + ) } diff --git a/src/components/BaseLayout.tsx b/src/components/BaseLayout.tsx index 78b8d15..b7ba334 100644 --- a/src/components/BaseLayout.tsx +++ b/src/components/BaseLayout.tsx @@ -1,6 +1,6 @@ -import { Link } from 'react-router' -import { useSSRData } from '~/lib/ssr-data' +import { Link, } from 'react-router' import UserMenu from '~/components/UserMenu' +import { useSSRData, } from '~/lib/ssr-data' interface MirrorConfig { enabled: boolean @@ -8,43 +8,41 @@ interface MirrorConfig { upstream: string } -export default function BaseLayout({ children }: { children: React.ReactNode }) { - const currentUser = useSSRData('currentUser') - const mirrorConfig = useSSRData('mirrorConfig') +export default function BaseLayout({ children, }: { children: React.ReactNode },) { + const currentUser = useSSRData('currentUser',) + const mirrorConfig = useSSRData('mirrorConfig',) return ( <> -
-