diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c30b2c6..a4e855c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,11 +31,28 @@ jobs: timeout-minutes: 60 runs-on: ubuntu-latest + services: + postgres: + image: postgres + env: + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5433:5432 + steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* + - name: Set DATABASE_URL environment variable + run: echo "DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres" >> $GITHUB_ENV - uses: ./.github/actions/ - name: Install Playwright Browsers run: npx playwright install --with-deps diff --git a/package-lock.json b/package-lock.json index 267c8ca..62d426b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -45,6 +46,52 @@ "node": ">=6.0.0" } }, + "node_modules/@ark/schema": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.10.0.tgz", + "integrity": "sha512-zpfXwWLOzj9aUK+dXQ6aleJAOgle4/WrHDop5CMX2M88dFQ85NdH8O0v0pvMAQnfFcaQAZ/nVDYLlBJsFc09XA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/util": "0.10.0" + } + }, + "node_modules/@ark/util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.10.0.tgz", + "integrity": "sha512-uK+9VU5doGMYOoOZVE+XaSs1vYACoaEJdrDkuBx26S4X7y3ChyKsPnIg/9pIw2vUySph1GkAXbvBnfVE2GmXgQ==", + "dev": true, + "optional": true + }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@effect/schema": { + "version": "0.75.5", + "resolved": "https://registry.npmjs.org/@effect/schema/-/schema-0.75.5.tgz", + "integrity": "sha512-TQInulTVCuF+9EIbJpyLP6dvxbQJMphrnRqgexm/Ze39rSjfhJuufF7XvU3SxTgg3HnL7B/kpORTJbHhlE6thw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fast-check": "^3.21.0" + }, + "peerDependencies": { + "effect": "^3.9.2" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -573,6 +620,14 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/schemasafe": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", + "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@floating-ui/core": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", @@ -601,6 +656,51 @@ "dev": true, "license": "MIT" }, + "node_modules/@gcornut/valibot-json-schema": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@gcornut/valibot-json-schema/-/valibot-json-schema-0.31.0.tgz", + "integrity": "sha512-3xGptCurm23e7nuPQkdrE5rEs1FeTPHhAUsBuwwqG4/YeZLwJOoYZv+fmsppUEfo5y9lzUwNQrNqLS/q7HMc7g==", + "dev": true, + "optional": true, + "dependencies": { + "valibot": "~0.31.0" + }, + "bin": { + "valibot-json-schema": "bin/index.js" + }, + "optionalDependencies": { + "@types/json-schema": ">= 7.0.14", + "esbuild": ">= 0.18.20", + "esbuild-runner": ">= 2.2.2" + } + }, + "node_modules/@gcornut/valibot-json-schema/node_modules/valibot": { + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.31.1.tgz", + "integrity": "sha512-2YYIhPrnVSz/gfT2/iXVTrSj92HwchCt9Cga/6hX4B26iCz9zkIsGTS0HjDYTZfTi1Un0X6aRvhBi1cfqs/i0Q==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -685,6 +785,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", @@ -699,6 +800,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -708,6 +810,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -717,12 +820,14 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -822,6 +927,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@poppinss/macroable": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@poppinss/macroable/-/macroable-1.0.3.tgz", + "integrity": "sha512-B4iV6QxW//Fn17+qF1EMZRmoThIUJlCtcO85yoRDJnMyHeAthjz4ig9OTkfGGXKtQhcdPX0me75gU5K9J897+w==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.16.0" + } + }, "node_modules/@prisma-idb/prisma-idb-generator": { "resolved": "packages/generator", "link": true @@ -1190,6 +1306,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sinclair/typebox": { + "version": "0.32.35", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.32.35.tgz", + "integrity": "sha512-Ul3YyOTU++to8cgNkttakC0dWvpERr6RYoHO2W47DLbFvrwBDJUY31B1sImH6JZSYc4Kt4PyHtoPNu+vL2r2dA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@sveltejs/adapter-auto": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.3.1.tgz", @@ -1203,6 +1354,16 @@ "@sveltejs/kit": "^2.0.0" } }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.6.tgz", + "integrity": "sha512-MGJcesnJWj7FxDcB/GbrdYD3q24Uk0PIL4QIX149ku+hlJuj//nxUbb0HxUTpjkecWfHjVveSUnUaQWnPRXlpg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, "node_modules/@sveltejs/kit": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.7.3.tgz", @@ -1359,6 +1520,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1392,6 +1554,49 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.12.2", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz", + "integrity": "sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@typeschema/class-validator": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@typeschema/class-validator/-/class-validator-0.3.0.tgz", + "integrity": "sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@typeschema/core": "0.14.0" + }, + "peerDependencies": { + "class-validator": "^0.14.1" + }, + "peerDependenciesMeta": { + "class-validator": { + "optional": true + } + } + }, + "node_modules/@typeschema/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@typeschema/core/-/core-0.14.0.tgz", + "integrity": "sha512-Ia6PtZHcL3KqsAWXjMi5xIyZ7XMH4aSnOQes8mfMLx+wGFGtGRNlwe6Y7cYvX+WfNK67OL0/HSe9t8QDygV0/w==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "@types/json-schema": "^7.0.15" + }, + "peerDependenciesMeta": { + "@types/json-schema": { + "optional": true + } + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.12.1.tgz", @@ -1634,10 +1839,43 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vinejs/compiler": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@vinejs/compiler/-/compiler-2.5.0.tgz", + "integrity": "sha512-hg4ekaB5Y2zh+IWzBiC/WCDWrIfpVnKu/ubUvelKlidc/VbulsexoFRw5kJGHZenPVI5YzNnDeTdYSALkTV7jQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@vinejs/vine": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@vinejs/vine/-/vine-1.8.0.tgz", + "integrity": "sha512-Qq3XxbA26jzqS9ICifkqzT399lMQZ2fWtqeV3luI2as+UIK7qDifJFU2Q4W3q3IB5VXoWxgwAZSZEO0em9I/qQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@poppinss/macroable": "^1.0.1", + "@types/validator": "^13.11.9", + "@vinejs/compiler": "^2.4.1", + "camelcase": "^8.0.0", + "dayjs": "^1.11.10", + "dlv": "^1.1.3", + "normalize-url": "^8.0.1", + "validator": "^13.11.0" + }, + "engines": { + "node": ">=18.16.0" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1660,6 +1898,7 @@ "version": "1.4.13", "resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz", "integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==", + "dev": true, "license": "MIT", "peerDependencies": { "acorn": ">=8.9.0" @@ -1749,11 +1988,24 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" } }, + "node_modules/arktype": { + "version": "2.0.0-rc.8", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.0.0-rc.8.tgz", + "integrity": "sha512-ByrqjptsavUCUL9ptts6BUL2LCNkVZyniOdaBw76dlBQ6gYIhYSeycuuj4gRFwcAafszOnAPD2fAqHK7bbo/Zw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/schema": "0.10.0", + "@ark/util": "0.10.0" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", @@ -1932,6 +2184,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1957,9 +2210,9 @@ } }, "node_modules/bits-ui": { - "version": "1.0.0-next.33", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-1.0.0-next.33.tgz", - "integrity": "sha512-f+WF0qMgF19caW7H3Xe1nBzSCoaj8q4GY5vhsR4w2HMXuSSlLfXbn1DxB/6rCU6DYye2AoAVJXKgSRn3MksFGA==", + "version": "1.0.0-next.57", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-1.0.0-next.57.tgz", + "integrity": "sha512-meXdwUM/fQv5bq6OgZCtGLAZjhBS1adVOu8rty5gLZ71bIbnK8/VHLX8FVSzIP99d0BglybxD7eAyjnZoelZJg==", "dev": true, "license": "MIT", "dependencies": { @@ -2038,6 +2291,14 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -2068,6 +2329,20 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2132,6 +2407,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/class-validator": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", + "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.10.53", + "validator": "^13.9.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2277,6 +2565,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -2389,6 +2685,17 @@ "dev": true, "license": "MIT" }, + "node_modules/effect": { + "version": "3.10.15", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.10.15.tgz", + "integrity": "sha512-LdczPAFbtij3xGr9i+8PyDtuWdlXjSY5UJ8PKrYrr0DClKfR/OW3j8sxtambWYljzJAYD865KFhv7LdbWdG7VQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fast-check": "^3.21.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.49", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.49.tgz", @@ -2596,6 +2903,32 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/esbuild-runner": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esbuild-runner/-/esbuild-runner-2.2.2.tgz", + "integrity": "sha512-fRFVXcmYVmSmtYm2mL8RlUASt2TDkGh3uRcvHFOKNr/T58VrfVeKD9uT9nlgxk96u0LS0ehS/GY7Da/bXWKkhw==", + "dev": true, + "license": "Apache License 2.0", + "optional": true, + "dependencies": { + "source-map-support": "0.5.21", + "tslib": "2.4.0" + }, + "bin": { + "esr": "bin/esr.js" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/esbuild-runner/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3015,6 +3348,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.1.4.tgz", "integrity": "sha512-oO82nKPHKkzIj/hbtuDYy/JHqBHFlMIW36SDiPCVsj87ntDLcWN+sJ1erdVryd4NxODacFTsdrIE3b7IamqbOg==", + "dev": true, "license": "MIT" }, "node_modules/espree": { @@ -3052,6 +3386,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.2.2.tgz", "integrity": "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", @@ -3091,6 +3426,30 @@ "node": ">=0.10.0" } }, + "node_modules/fast-check": { + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.1.tgz", + "integrity": "sha512-u/MudsoQEgBUZgR5N1v87vEgybeVYus9VnDVaIkxkkGP2jt54naghQ3PCQHJiogS8U/GavZCUPFfx3Xkp+NaHw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3243,6 +3602,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formsnap": { + "version": "2.0.0-next.1", + "resolved": "https://registry.npmjs.org/formsnap/-/formsnap-2.0.0-next.1.tgz", + "integrity": "sha512-ha8r9eMmsGEGMY+ljV3FEyTtB72E7dt95y9HHUbCcaDnjbz3Q6n00BHLz7dfBZ9rqyaMeIO200EmP1IcYMExeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "svelte-toolbelt": "^0.4.4" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "sveltekit-superforms": "^2.19.0" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -3867,6 +4244,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "*" @@ -4006,6 +4384,21 @@ "jiti": "bin/jiti.js" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -4026,6 +4419,21 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4065,6 +4473,13 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/just-clone": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-clone/-/just-clone-6.2.0.tgz", + "integrity": "sha512-1IynUYEc/HAwxhi3WDpIpxJbZpMCvvrrmZVqvj9EhpvbH8lls7HhdhiByjL7DkAaWlLIzpC0Xc/VPvy/UxLNjA==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4106,6 +4521,14 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.11.14", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.14.tgz", + "integrity": "sha512-sexvAfwcW1Lqws4zFp8heAtAEXbEDnvkYCEGzvOoMgZR7JhXo/IkE9MkkGACgBed5fWqh3ShBGnJBdDnU9N8EQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -4127,6 +4550,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -4187,11 +4611,19 @@ "version": "0.30.12", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/memoize-weak": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", + "integrity": "sha512-gj39xkrjEw7nCn4nJ1M5ms6+MyMlyiGmttzsqAUsAKn6bYKwuTHh/AO3cKPF8IBrTIYTxb0wWXFs3E//Y8VoWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4253,6 +4685,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-0.4.1.tgz", "integrity": "sha512-bNC+1NXmwEFZtziCdZSgP7HFQTpqJPcQn9GwwJQGSf6SBF3neEPYV1uRwkYuAQwbsvsXIYtzaqgedDzJ7D1mhg==", + "dev": true, "license": "MIT", "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.1" @@ -4350,6 +4783,20 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", + "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5025,6 +5472,14 @@ "node": ">=6" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5035,6 +5490,24 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5080,6 +5553,14 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -5403,6 +5884,17 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5413,6 +5905,18 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -5625,6 +6129,17 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/superstruct": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", + "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5655,6 +6170,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.1.4.tgz", "integrity": "sha512-qgHDV7AyvBZa2pbf+V0tnvWrN1LKD8LdUsBkR/SSYVVN6zXexiXnOy5Pjcjft2y/2NJJVa8ORUHFVn3oiWCLVQ==", + "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -5805,6 +6321,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/svelte-sonner": { + "version": "0.3.28", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-0.3.28.tgz", + "integrity": "sha512-K3AmlySeFifF/cKgsYNv5uXqMVNln0NBAacOYgmkQStLa/UoU0LhfAACU6Gr+YYC8bOCHdVmFNoKuDbMEsppJg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" + } + }, "node_modules/svelte-toolbelt": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.4.4.tgz", @@ -5825,6 +6351,109 @@ "svelte": "^5.0.0-next.126" } }, + "node_modules/sveltekit-superforms": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/sveltekit-superforms/-/sveltekit-superforms-2.20.0.tgz", + "integrity": "sha512-5HyA6THKFBHEmJinZ/klu2/0jYr9ElSaXMYc5EO9ptP3x1wQPWVXYl59sMcaSrIjWUlPpayGxVppCyu+x/o4WA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ciscoheat" + }, + { + "type": "ko-fi", + "url": "https://ko-fi.com/ciscoheat" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=NY7F5ALHHSVQS" + } + ], + "license": "MIT", + "dependencies": { + "devalue": "^5.1.1", + "just-clone": "^6.2.0", + "memoize-weak": "^1.0.2", + "ts-deepmerge": "^7.0.1" + }, + "optionalDependencies": { + "@effect/schema": "^0.75.3", + "@exodus/schemasafe": "^1.3.0", + "@gcornut/valibot-json-schema": "^0.31.0", + "@sinclair/typebox": "^0.32.35", + "@typeschema/class-validator": "^0.3.0", + "@vinejs/vine": "^1.8.0", + "arktype": "2.0.0-rc.8", + "class-validator": "^0.14.1", + "effect": "^3.9.1", + "joi": "^17.13.3", + "json-schema-to-ts": "^3.1.1", + "superstruct": "^2.0.2", + "valibot": "^0.41.0", + "yup": "^1.4.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.3" + }, + "peerDependencies": { + "@effect/schema": "^0.75.3", + "@exodus/schemasafe": "^1.3.0", + "@sinclair/typebox": ">=0.32.30 <1", + "@sveltejs/kit": "1.x || 2.x", + "@typeschema/class-validator": "^0.3.0", + "@vinejs/vine": "^1.8.0", + "arktype": ">=2.0.0-rc.8", + "class-validator": "^0.14.1", + "effect": "^3.8.2", + "joi": "^17.13.1", + "superstruct": "^2.0.2", + "svelte": "3.x || 4.x || >=5.0.0-next.51", + "valibot": ">=0.33.0 <1", + "yup": "^1.4.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "@effect/schema": { + "optional": true + }, + "@exodus/schemasafe": { + "optional": true + }, + "@sinclair/typebox": { + "optional": true + }, + "@typeschema/class-validator": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "arktype": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "effect": { + "optional": true + }, + "joi": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "valibot": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/tailwind-merge": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz", @@ -6068,6 +6697,14 @@ "node": ">=0.8" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", @@ -6131,6 +6768,14 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -6141,6 +6786,14 @@ "node": ">=6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -6154,6 +6807,16 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-deepmerge": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-7.0.1.tgz", + "integrity": "sha512-JBFCmNenZdUCc+TRNCtXVM6N8y/nDQHAcpj5BlwXG/gnogjam1NunulB9ia68mnqYI446giMfpqeBFFkOleh+g==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14.13.1" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -6204,6 +6867,20 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", @@ -6416,6 +7093,33 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/valibot": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.41.0.tgz", + "integrity": "sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vite": { "version": "5.4.10", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", @@ -6691,15 +7395,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", + "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zimmerframe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", + "dev": true, "license": "MIT" }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.23.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.5.tgz", + "integrity": "sha512-5wlSS0bXfF/BrL4jPAbz9da5hDlDptdEppYfe+x4eIJ7jioqKG9uUxOwPzqof09u/XeVdrgFu29lZi+8XNDJtA==", + "dev": true, + "license": "ISC", + "optional": true, + "peerDependencies": { + "zod": "^3.23.3" + } + }, "packages/generator": { "name": "@prisma-idb/prisma-idb-generator", - "version": "0.0.15", + "version": "0.0.20", "license": "MIT", "dependencies": { "@prisma/client": "5.21.1", @@ -6728,7 +7468,6 @@ "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "idb": "^8.0.0", - "mode-watcher": "^0.4.1", "uuid": "^11.0.2" }, "devDependencies": { @@ -6736,31 +7475,37 @@ "@prisma-idb/prisma-idb-generator": "*", "@prisma/client": "^5.21.1", "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tailwindcss/typography": "^0.5.15", "@types/eslint": "^9.6.0", "autoprefixer": "^10.4.20", - "bits-ui": "^1.0.0-next.33", + "bits-ui": "^1.0.0-next.57", "clsx": "^2.1.1", "eslint": "^9.7.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.36.0", + "formsnap": "^2.0.0-next.1", "globals": "^15.0.0", "lucide-svelte": "^0.454.0", + "mode-watcher": "^0.4.1", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.6", "prettier-plugin-tailwindcss": "^0.6.5", "prisma": "^5.21.1", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "svelte-sonner": "^0.3.28", + "sveltekit-superforms": "^2.20.0", "tailwind-merge": "^2.5.4", "tailwind-variants": "^0.2.1", "tailwindcss": "^3.4.9", "tailwindcss-animate": "^1.0.7", "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", - "vite": "^5.0.3" + "vite": "^5.0.3", + "zod": "^3.23.8" } } } diff --git a/packages/generator/src/Aggregate Functions/aggregate.ts b/packages/generator/src/Aggregate Functions/aggregate.ts deleted file mode 100644 index 773bdfd..0000000 --- a/packages/generator/src/Aggregate Functions/aggregate.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { ClassDeclaration, CodeBlockWriter } from "ts-morph"; - -export function addAggregateMethod(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "aggregate", - isAsync: true, - typeParameters: [{ name: "Q", constraint: "Prisma.Args" }], - parameters: [{ name: "query", type: "Q" }], - returnType: `Promise>`, - statements: (writer) => { - writeRetrieveRecords(writer); - writeWhereClause(writer); - writeResultsInitialization(writer); - writeCountCalculation(writer); - writeSumCalculation(writer); - writeMinCalculation(writer); - writeMaxCalculation(writer); - writeReturnResults(writer); - }, - }); -} - -function writeRetrieveRecords(writer: CodeBlockWriter) { - writer.writeLine("let records = await this.client.db.getAll(this.model.name);").blankLine(); -} - -function writeWhereClause(writer: CodeBlockWriter) { - writer - .writeLine("if (query.where) {") - .indent(() => { - writer.writeLine( - "records = filterByWhereClause(await this.client.db.getAll(this.model.name), this.keyPath, query.where);", - ); - }) - .writeLine("}") - .blankLine(); -} - -function writeResultsInitialization(writer: CodeBlockWriter) { - writer.writeLine("const results = {};").blankLine(); -} - -function writeCountCalculation(writer: CodeBlockWriter) { - writer - .writeLine("if (query._count) {") - .indent(() => { - writer - .writeLine("const calculateCount = (records, countQuery) => {") - .indent(() => { - writer - .writeLine("const [key] = Object.keys(countQuery);") - .writeLine("return records.filter(record => key in record && record[key] === countQuery[key]).length;"); - }) - .writeLine("};") - .writeLine("results._count = calculateCount(records, query._count);"); - }) - .writeLine("}") - .blankLine(); -} - -function writeSumCalculation(writer: CodeBlockWriter) { - writer - .writeLine("if (query._sum) {") - .indent(() => { - writer - .writeLine("const calculateSum = (records, sumQuery) => {") - .indent(() => { - writer - .writeLine("const [key] = Object.keys(sumQuery);") - .writeLine("const numericValues = records") - .indent(() => { - writer - .writeLine(".map(record => (typeof record[key] === 'number' ? record[key] : null))") - .writeLine(".filter(value => value !== null);"); - }) - .writeLine("return numericValues.length ? numericValues.reduce((acc, val) => acc + val, 0) : 0;"); - }) - .writeLine("};") - .writeLine("results._sum = calculateSum(records, query._sum);"); - }) - .writeLine("}") - .blankLine(); -} - -function writeMinCalculation(writer: CodeBlockWriter) { - writer - .writeLine("if (query._min) {") - .indent(() => { - writer - .writeLine("const calculateMin = (records, minQuery) => {") - .indent(() => { - writer - .writeLine("const [key] = Object.keys(minQuery);") - .writeLine("const numericValues = records") - .indent(() => { - writer - .writeLine(".map(record => (typeof record[key] === 'number' ? record[key] : null))") - .writeLine(".filter(value => value !== null);"); - }) - .writeLine("return numericValues.length ? Math.min(...numericValues) : null;"); - }) - .writeLine("};") - .writeLine("results._min = calculateMin(records, query._min);"); - }) - .writeLine("}") - .blankLine(); -} - -function writeMaxCalculation(writer: CodeBlockWriter) { - writer - .writeLine("if (query._max) {") - .indent(() => { - writer - .writeLine("const calculateMax = (records, maxQuery) => {") - .indent(() => { - writer - .writeLine("const [key] = Object.keys(maxQuery);") - .writeLine("const numericValues = records") - .indent(() => { - writer - .writeLine(".map(record => (typeof record[key] === 'number' ? record[key] : null))") - .writeLine(".filter(value => value !== null);"); - }) - .writeLine("return numericValues.length ? Math.max(...numericValues) : null;"); - }) - .writeLine("};") - .writeLine("results._max = calculateMax(records, query._max);"); - }) - .writeLine("}") - .blankLine(); -} - -function writeReturnResults(writer: CodeBlockWriter) { - writer.writeLine("return results as Prisma.Result;"); -} diff --git a/packages/generator/src/Aggregate Functions/count.ts b/packages/generator/src/Aggregate Functions/count.ts deleted file mode 100644 index 21184ce..0000000 --- a/packages/generator/src/Aggregate Functions/count.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ClassDeclaration } from "ts-morph"; - -export function addCountMethod(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "count", - isAsync: true, - typeParameters: [{ name: "Q", constraint: "Prisma.Args" }], - parameters: [{ name: "query", type: `Q` }], - returnType: `Promise>`, - statements: (writer) => { - writer - .writeLine(`const records = filterByWhereClause(`) - .indent(() => { - writer - .writeLine(`await this.client.db.getAll(this.model.name),`) - .writeLine(`this.keyPath,`) - .writeLine(`query?.where,`); - }) // filter by where clause - .writeLine(`)`) - .writeLine('return records.length as Prisma.Result;'); // return the respective filtered record length - }, - }); -} diff --git a/packages/generator/src/CRUD/create.ts b/packages/generator/src/CRUD/create.ts deleted file mode 100644 index c63e414..0000000 --- a/packages/generator/src/CRUD/create.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ClassDeclaration } from "ts-morph"; - -// TODO: nested creates, connects, and createMany - -export function addCreateMethod(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "create", - isAsync: true, - typeParameters: [{ name: "Q", constraint: 'Prisma.Args' }], - returnType: 'Promise>', - parameters: [{ name: "query", type: "Q" }], - statements: (writer) => { - writer - .writeLine("const record = await this.fillDefaults(query.data);") - .writeLine("await this.client.db.add(this.model.name, record);") - .writeLine(`this.emit("create");`) - .writeLine(`return record as Prisma.Result;`); - }, - }); -} diff --git a/packages/generator/src/CRUD/createMany.ts b/packages/generator/src/CRUD/createMany.ts deleted file mode 100644 index 65eeb8d..0000000 --- a/packages/generator/src/CRUD/createMany.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ClassDeclaration } from "ts-morph"; - -export function addCreateManyMethod(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "createMany", - isAsync: true, - typeParameters: [{ name: "Q", constraint: 'Prisma.Args' }], - parameters: [{ name: "query", type: `Q` }], - returnType: 'Promise>', - statements: (writer) => { - writer - .writeLine('const tx = this.client.db.transaction(this.model.name, "readwrite");') - .writeLine("const queryData = Array.isArray(query.data) ? query.data : [query.data];") - .writeLine( - "await Promise.all([...queryData.map(async (record) => tx.store.add(await this.fillDefaults(record))), tx.done]);", - ) - .writeLine('this.emit("create");') - .writeLine('return { count: queryData.length } as Prisma.Result;'); - }, - }); -} diff --git a/packages/generator/src/CRUD/delete.ts b/packages/generator/src/CRUD/delete.ts deleted file mode 100644 index 1bb536c..0000000 --- a/packages/generator/src/CRUD/delete.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ClassDeclaration } from "ts-morph"; - -// TODO: handle cascades -// TODO: use indexes wherever possible - -export function addDeleteMethod(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "delete", - isAsync: true, - typeParameters: [{ name: "Q", constraint: 'Prisma.Args' }], - parameters: [{ name: "query", type: `Q` }], - returnType: 'Promise>', - statements: (writer) => { - writer - .writeLine(`const records = filterByWhereClause(`) - .indent(() => { - writer - .writeLine(`await this.client.db.getAll(this.model.name),`) - .writeLine(`this.keyPath,`) - .writeLine(`query.where,`); - }) - .writeLine(`)`) - .writeLine(`if (records.length === 0) throw new Error("Record not found");`) - .blankLine() - .writeLine(`await this.client.db.delete(`) - .indent(() => { - writer - .writeLine(`this.model.name,`) - .write( - `this.keyPath.map((keyField) => records[0][keyField as keyof typeof records[number]] as IDBValidKey) `, - ) - .write('as PrismaIDBSchema[typeof this.model.name]["key"]'); - }) - .writeLine(`);`) - .writeLine(`this.emit("delete");`) - .writeLine(`return records[0] as Prisma.Result;`); - }, - }); -} diff --git a/packages/generator/src/CRUD/deleteMany.ts b/packages/generator/src/CRUD/deleteMany.ts deleted file mode 100644 index bdc38e3..0000000 --- a/packages/generator/src/CRUD/deleteMany.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ClassDeclaration } from "ts-morph"; - -// TODO: handle cascades -// TODO: use indexes wherever possible - -export function addDeleteManyMethod(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "deleteMany", - isAsync: true, - typeParameters: [{ name: "Q", constraint: 'Prisma.Args' }], - parameters: [{ name: "query", type: `Q` }], - returnType: "Promise>", - statements: (writer) => { - writer - .writeLine(`const records = filterByWhereClause(`) - .indent(() => { - writer - .writeLine(`await this.client.db.getAll(this.model.name),`) - .writeLine(`this.keyPath,`) - .writeLine(`query?.where,`); - }) - .writeLine(`)`) - .writeLine(`if (records.length === 0) return { count: 0 } as Prisma.Result;`) - .blankLine() - .writeLine(`const tx = this.client.db.transaction(this.model.name, "readwrite");`) - .writeLine(`await Promise.all([`) - .indent(() => { - writer - .writeLine(`...records.map((record) => `) - .indent(() => { - writer - .write( - `tx.store.delete(this.keyPath.map((keyField) => record[keyField as keyof typeof record] as IDBValidKey) `, - ) - .write('as PrismaIDBSchema[typeof this.model.name]["key"])'); - }) - .writeLine(`),`) - .writeLine(`tx.done,`); - }) - .writeLine(`]);`) - .writeLine(`this.emit("delete");`) - .writeLine('return { count: records.length } as Prisma.Result;'); - }, - }); -} diff --git a/packages/generator/src/CRUD/findFirst.ts b/packages/generator/src/CRUD/findFirst.ts deleted file mode 100644 index 42bd3ac..0000000 --- a/packages/generator/src/CRUD/findFirst.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ClassDeclaration } from "ts-morph"; - -export function addFindFirstMethod(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "findFirst", - isAsync: true, - typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], - parameters: [{ name: "query?", type: "Q" }], - returnType: `Promise | null>`, - statements: (writer) => { - writer.writeLine( - 'return ((await this.findMany(query))[0] as Prisma.Result | undefined) ?? null;', - ); - }, - }); -} diff --git a/packages/generator/src/CRUD/findMany.ts b/packages/generator/src/CRUD/findMany.ts deleted file mode 100644 index 5715bc6..0000000 --- a/packages/generator/src/CRUD/findMany.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ClassDeclaration } from "ts-morph"; - -// TODO: orderBy, indexes, nested select, include, where - -export function addFindManyMethod(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "findMany", - isAsync: true, - typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], - parameters: [{ name: "query?", type: "Q" }], - returnType: `Promise>`, - statements: (writer) => { - writer - .writeLine( - 'const records = (await this.client.db.getAll(this.model.name)) as Prisma.Result[];', - ) - .writeLine(`return filterByWhereClause(`) - .indent(() => { - writer.writeLine(`records, this.keyPath, query?.where`); - }) - .writeLine(`) as Prisma.Result;`); - }, - }); -} diff --git a/packages/generator/src/CRUD/findUnique.ts b/packages/generator/src/CRUD/findUnique.ts deleted file mode 100644 index 05015da..0000000 --- a/packages/generator/src/CRUD/findUnique.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ClassDeclaration, CodeBlockWriter } from "ts-morph"; - -// TODO: select, include, and where clauses (also nested versions) - -export function addFindUniqueMethod(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "findUnique", - isAsync: true, - typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], - parameters: [{ name: "query", type: "Q" }], - returnType: `Promise | null>`, - statements: (writer) => writeFindUniqueFunction(writer), - }); -} - -function writeFindUniqueFunction(writer: CodeBlockWriter) { - writer.writeLine("const queryWhere = query.where as Record;"); - - writePrimaryKeyCheck(writer); - writeIdentifierFieldCheck(writer); - writeNonKeyUniqueFieldsLoop(writer); - - writer.writeLine("throw new Error('No unique field provided for findUnique');"); -} - -function writePrimaryKeyCheck(writer: CodeBlockWriter) { - writer.writeLine("if (this.model.primaryKey && this.model.primaryKey.fields.length > 1)").block(() => { - writer.writeLine( - 'const keyFieldValue = queryWhere[this.model.primaryKey.fields.join("_")] as Record;', - ); - writer.writeLine( - 'const tupleKey = this.keyPath.map((key) => keyFieldValue[key]) as PrismaIDBSchema[typeof this.model.name]["key"];', - ); - writer.writeLine("const foundRecord = await this.client.db.get(this.model.name, tupleKey);"); - writer.writeLine("if (!foundRecord) return null;"); - - writer - .writeLine("return (") - .write("filterByWhereClause(") - .writeLine("[foundRecord],") - .writeLine("this.keyPath,") - .writeLine("query.where,") - .write(")[0] as Prisma.Result) ?? null;"); - }); -} - -function writeIdentifierFieldCheck(writer: CodeBlockWriter) { - writer.writeLine("else").block(() => { - writer.writeLine("const identifierFieldName = JSON.parse(generateIDBKey(this.model))[0];"); - - writer.writeLine("if (queryWhere[identifierFieldName])").block(() => { - writer - .write("return ((await this.client.db.get(") - .write( - 'this.model.name, [queryWhere[identifierFieldName]] as unknown as PrismaIDBSchema[typeof this.model.name]["key"])) ?? null) ', - ) - .write('as Prisma.Result;') - .newLine(); - }); - }); -} - -function writeNonKeyUniqueFieldsLoop(writer: CodeBlockWriter) { - writer - .writeLine("getModelFieldData(this.model)") - .write(".nonKeyUniqueFields.map(({ name }) => name)") - .write(".forEach(async (uniqueField) => {") - .block(() => { - writer.writeLine("if (!queryWhere[uniqueField]) return;"); - writer - .write("return (await this.client.db.getFromIndex(") - .write("this.model.name, ") - .write("`${uniqueField}Index`, ") - .write("queryWhere[uniqueField] as IDBValidKey") - .write(")) ?? null;") - .newLine(); - }) - .writeLine("});"); -} diff --git a/packages/generator/src/CRUD/update.ts b/packages/generator/src/CRUD/update.ts deleted file mode 100644 index 8c57895..0000000 --- a/packages/generator/src/CRUD/update.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ClassDeclaration, CodeBlockWriter } from "ts-morph"; - -// TODO: indexes, nested select, include, where, list updates, object updates, operational updates (string, int (increment, etc)) - -export function addUpdateMethod(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "update", - isAsync: true, - typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], - parameters: [{ name: "query", type: "Q" }], - returnType: `Promise>`, - statements: (writer) => { - writer - .writeLine("const record = await this.findFirst(query);") - .writeLine("if (record === null) throw new Error('Record not found');") - .blankLine() - .writeLine("this.model.fields.forEach((field) => {") - .indent(() => { - writer - .writeLine("const fieldName = field.name as keyof typeof record & keyof typeof query.data;") - .writeLine("if (query.data[fieldName] !== undefined) {") - .indent(() => { - handleFieldUpdates(writer); - }) - .writeLine("}"); - }) - .writeLine("});") - .writeLine("await this.client.db.put(this.model.name, record);") - .writeLine("this.emit('update');") - .writeLine("return record as Prisma.Result;"); - }, - }); -} - -function handleFieldUpdates(writer: CodeBlockWriter) { - writer - .writeLine("if (field.kind === 'object') {") - .indent(() => writer.writeLine("throw new Error('Object updates not yet supported');")) - .writeLine("} else if (field.isList) {") - .indent(() => writer.writeLine("throw new Error('List updates not yet supported');")) - .writeLine("} else {") - .indent(() => { - handlePrimitiveFieldUpdates(writer); - }) - .writeLine("}"); -} - -function handlePrimitiveFieldUpdates(writer: CodeBlockWriter) { - writer - .writeLine("const fieldType = field.type as typeof prismaToJsTypes extends Map ? K : never;") - .writeLine("const jsType = prismaToJsTypes.get(fieldType);") - .writeLine("if (!jsType || jsType === 'unknown') throw new Error(`Unsupported type: ${field.type}`);") - .blankLine() - .writeLine("if (typeof query.data[fieldName] === jsType) {") - .indent(() => writer.writeLine("record[fieldName] = query.data[fieldName];")) - .writeLine("} else {") - .indent(() => writer.writeLine("throw new Error('Indirect updates not yet supported');")) - .writeLine("}"); -} diff --git a/packages/generator/src/fileBaseFunctions.ts b/packages/generator/src/fileBaseFunctions.ts deleted file mode 100644 index 7b5cd81..0000000 --- a/packages/generator/src/fileBaseFunctions.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { ClassDeclaration, CodeBlockWriter, Scope, SourceFile } from "ts-morph"; -import { addCountMethod } from "./Aggregate Functions/count"; -import { addCreateMethod } from "./CRUD/create"; -import { addCreateManyMethod } from "./CRUD/createMany"; -import { addDeleteMethod } from "./CRUD/delete"; -import { addDeleteManyMethod } from "./CRUD/deleteMany"; -import { addFindFirstMethod } from "./CRUD/findFirst"; -import { addFindManyMethod } from "./CRUD/findMany"; -import { addFindUniqueMethod } from "./CRUD/findUnique"; -import { addUpdateMethod } from "./CRUD/update"; -import { addFillDefaultsFunction } from "./fillDefaultsFunction"; -import { Model } from "./types"; -import { generateIDBKey, getModelFieldData, toCamelCase } from "./utils"; - -export function addImports(file: SourceFile) { - file.addImportDeclaration({ moduleSpecifier: "idb", namedImports: ["openDB"] }); - file.addImportDeclaration({ moduleSpecifier: "idb", namedImports: ["IDBPDatabase"], isTypeOnly: true }); - file.addImportDeclaration({ moduleSpecifier: "@prisma/client", namedImports: ["Prisma"], isTypeOnly: true }); - file.addImportDeclaration({ - moduleSpecifier: "./datamodel", - namespaceImport: "models", - }); - file.addImportDeclaration({ - moduleSpecifier: "./idb-interface", - namedImports: ["PrismaIDBSchema"], - isTypeOnly: true, - }); - file.addImportDeclaration({ - moduleSpecifier: "./utils", - namedImports: ["filterByWhereClause", "generateIDBKey", "getModelFieldData", "prismaToJsTypes"], - }); - file.addImportDeclaration({ - moduleSpecifier: "./utils", - namedImports: ["Model"], - isTypeOnly: true, - }); -} - -function addObjectStoreInitialization(model: Model, writer: CodeBlockWriter) { - const { nonKeyUniqueFields } = getModelFieldData(model); - const keyPath = generateIDBKey(model); - - let declarationLine = nonKeyUniqueFields.length ? `const ${model.name}Store = ` : ``; - declarationLine += `db.createObjectStore('${model.name}', { keyPath: ${keyPath} });`; - - writer.writeLine(declarationLine); - nonKeyUniqueFields.forEach(({ name }) => { - // TODO: perhaps an option to skip index creation on unique fields in the generator config? - writer.writeLine(`${model.name}Store.createIndex("${name}Index", "${name}", { unique: true });`); - }); -} - -export function addTypes(file: SourceFile, models: readonly Model[]) { - file.addTypeAliases([ - { - name: "ModelDelegate", - isExported: true, - type: (writer) => { - models.forEach((model, idx) => { - writer.write(`Prisma.${model.name}Delegate`); - if (idx < models.length - 1) { - writer.write(" | "); - } - }); - }, - }, - { - name: "ObjectStoreName", - type: (writer) => writer.writeLine("(typeof PrismaIDBClient.prototype.db.objectStoreNames)[number]"), - }, - ]); -} - -export function addClientClass(file: SourceFile, models: readonly Model[]) { - const clientClass = file.addClass({ - name: "PrismaIDBClient", - isExported: true, - ctors: [{ scope: Scope.Private }], - properties: [ - { name: "instance", isStatic: true, type: "PrismaIDBClient", scope: Scope.Private }, - { name: "db", type: "IDBPDatabase", hasExclamationToken: true }, - ], - }); - - // Add model properties - models.forEach((model) => - clientClass.addProperty({ - name: toCamelCase(model.name), - type: `BaseIDBModelClass`, - hasExclamationToken: true, - }), - ); - - // Add the create() method - clientClass.addMethod({ - name: "create", - isStatic: true, - isAsync: true, - scope: Scope.Public, - returnType: "Promise", - statements: (writer) => { - writer - .writeLine("if (!PrismaIDBClient.instance) {") - .indent(() => { - writer - .writeLine("const client = new PrismaIDBClient();") - .writeLine("await client.initialize();") - .writeLine("PrismaIDBClient.instance = client;"); - }) - .writeLine("}") - .writeLine("return PrismaIDBClient.instance;"); - }, - }); - - // Add the initialize() method - clientClass.addMethod({ - name: "initialize", - scope: Scope.Private, - isAsync: true, - statements: (writer) => { - writer - .writeLine("this.db = await openDB('prisma-idb', IDB_VERSION, {") - .indent(() => { - writer - .writeLine("upgrade(db) {") - .indent(() => { - models.forEach((model) => addObjectStoreInitialization(model, writer)); - }) - .writeLine("}"); - }) - .writeLine("});"); - - // Set members as object references of model classes - models.forEach((model) => { - writer.writeLine( - `this.${toCamelCase(model.name)} = new BaseIDBModelClass(this, ${generateIDBKey(model)}, models.${model.name});`, - ); - }); - }, - }); -} - -export function addBaseModelClass(file: SourceFile) { - const baseModelClass = file.addClass({ - name: "BaseIDBModelClass", - typeParameters: [{ name: "T", constraint: "ModelDelegate" }], - properties: [ - { name: "client", type: "PrismaIDBClient", scope: Scope.Private }, - { name: "keyPath", type: "string[]", scope: Scope.Private }, - { name: "model", type: "Omit & { name: ObjectStoreName }", scope: Scope.Private }, - { name: "eventEmitter", type: "EventTarget", scope: Scope.Private }, - ], - ctors: [ - { - parameters: [ - { name: "client", type: "PrismaIDBClient" }, - { name: "keyPath", type: "string[]" }, - { name: "model", type: "Model" }, - ], - statements: (writer) => { - writer - .writeLine("this.client = client") - .writeLine("this.keyPath = keyPath") - .writeLine("this.model = model as Omit & { name: ObjectStoreName }") - .writeLine("this.eventEmitter = new EventTarget()"); - }, - }, - ], - }); - - addEventEmitters(baseModelClass); - addFillDefaultsFunction(baseModelClass); - - // Find methods - addFindManyMethod(baseModelClass); - addFindFirstMethod(baseModelClass); - addFindUniqueMethod(baseModelClass); - - // Create methods - addCreateMethod(baseModelClass); - addCreateManyMethod(baseModelClass); - - // Delete methods - addDeleteMethod(baseModelClass); - addDeleteManyMethod(baseModelClass); - - // Update methods - addUpdateMethod(baseModelClass); - - // Aggregate function methods - addCountMethod(baseModelClass); - - // Need a refactor - // addAggregateMethod(baseModelClass); -} - -export function addEventEmitters(baseModelClass: ClassDeclaration) { - baseModelClass.addMethods([ - { - name: "subscribe", - parameters: [ - { name: "event", type: `"create" | "update" | "delete" | ("create" | "update" | "delete")[]` }, - { name: "callback", type: "() => void" }, - ], - statements: (writer) => { - writer - .writeLine(`if (Array.isArray(event)) {`) - .indent(() => { - writer - .writeLine(`event.forEach((event) => this.eventEmitter.addEventListener(event, callback));`) - .writeLine(`return;`); - }) - .writeLine("}") - .writeLine(`this.eventEmitter.addEventListener(event, callback);`); - }, - }, - { - name: "unsubscribe", - parameters: [ - { name: "event", type: `"create" | "update" | "delete" | ("create" | "update" | "delete")[]` }, - { name: "callback", type: "() => void" }, - ], - statements: (writer) => { - writer - .writeLine(`if (Array.isArray(event)) {`) - .indent(() => - writer - .writeLine(`event.forEach((event) => this.eventEmitter.removeEventListener(event, callback));`) - .writeLine(`return;`), - ) - .writeLine("}") - .writeLine(`this.eventEmitter.removeEventListener(event, callback);`); - }, - }, - { - name: "emit", - parameters: [{ name: "event", type: `"create" | "update" | "delete"` }], - statements: (writer) => writer.writeLine(`this.eventEmitter.dispatchEvent(new Event(event));`), - scope: Scope.Private, - }, - ]); -} diff --git a/packages/generator/src/fileCreators/idb-interface/create.ts b/packages/generator/src/fileCreators/idb-interface/create.ts new file mode 100644 index 0000000..ff16d1c --- /dev/null +++ b/packages/generator/src/fileCreators/idb-interface/create.ts @@ -0,0 +1,37 @@ +import { DMMF } from "@prisma/generator-helper"; +import { CodeBlockWriter, SourceFile } from "ts-morph"; +import { getUniqueIdentifiers } from "../../helpers/utils"; +import { Model } from "../types"; + +export function createIDBInterfaceFile(idbInterfaceFile: SourceFile, models: DMMF.Datamodel["models"]) { + idbInterfaceFile.addImportDeclaration({ isTypeOnly: true, namedImports: ["DBSchema"], moduleSpecifier: "idb" }); + idbInterfaceFile.addImportDeclaration({ namespaceImport: "Prisma", moduleSpecifier: "@prisma/client" }); + + idbInterfaceFile.addInterface({ + name: "PrismaIDBSchema", + extends: ["DBSchema"], + isExported: true, + properties: models.map((model) => ({ + name: model.name, + type: (writer) => { + writer.block(() => { + writer + .writeLine(`key: ${getUniqueIdentifiers(model)[0].keyPathType};`) + .writeLine(`value: Prisma.${model.name};`); + createUniqueFieldIndexes(writer, model); + }); + }, + })), + }); +} + +function createUniqueFieldIndexes(writer: CodeBlockWriter, model: Model) { + const nonKeyUniqueIdentifiers = getUniqueIdentifiers(model).slice(1); + if (nonKeyUniqueIdentifiers.length === 0) return; + + writer.writeLine("indexes: ").block(() => { + nonKeyUniqueIdentifiers.forEach(({ name, keyPathType }) => { + writer.writeLine(`${name}Index: ${keyPathType}`); + }); + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/BaseIDBModelClass.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/BaseIDBModelClass.ts new file mode 100644 index 0000000..281f46f --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/BaseIDBModelClass.ts @@ -0,0 +1,73 @@ +import { SourceFile, Scope, ClassDeclaration } from "ts-morph"; + +export function addBaseModelClass(file: SourceFile) { + const baseModelClass = file.addClass({ + name: "BaseIDBModelClass", + properties: [ + { name: "client", type: "PrismaIDBClient", scope: Scope.Protected }, + { name: "keyPath", type: "string[]", scope: Scope.Protected }, + { name: "eventEmitter", type: "EventTarget", scope: Scope.Private }, + ], + ctors: [ + { + parameters: [ + { name: "client", type: "PrismaIDBClient" }, + { name: "keyPath", type: "string[]" }, + ], + statements: (writer) => { + writer + .writeLine("this.client = client;") + .writeLine("this.keyPath = keyPath;") + .writeLine("this.eventEmitter = new EventTarget();"); + }, + }, + ], + }); + + addEventEmitters(baseModelClass); +} + +function addEventEmitters(baseModelClass: ClassDeclaration) { + baseModelClass.addMethods([ + { + name: "subscribe", + parameters: [ + { name: "event", type: `"create" | "update" | "delete" | ("create" | "update" | "delete")[]` }, + { name: "callback", type: "() => void" }, + ], + statements: (writer) => { + writer + .writeLine(`if (Array.isArray(event))`) + .block(() => { + writer + .writeLine(`event.forEach((event) => this.eventEmitter.addEventListener(event, callback));`) + .writeLine(`return;`); + }) + .writeLine(`this.eventEmitter.addEventListener(event, callback);`); + }, + }, + { + name: "unsubscribe", + parameters: [ + { name: "event", type: `"create" | "update" | "delete" | ("create" | "update" | "delete")[]` }, + { name: "callback", type: "() => void" }, + ], + statements: (writer) => { + writer + .writeLine(`if (Array.isArray(event))`) + .block(() => + writer + .writeLine(`event.forEach((event) => this.eventEmitter.removeEventListener(event, callback));`) + .writeLine(`return;`), + ) + .writeLine(`this.eventEmitter.removeEventListener(event, callback);`); + }, + }, + { + name: "emit", + parameters: [{ name: "event", type: `"create" | "update" | "delete"` }], + statements: (writer) => writer.writeLine(`this.eventEmitter.dispatchEvent(new Event(event));`), + scope: Scope.Protected, + }, + ]); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/PrismaIDBClient.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/PrismaIDBClient.ts new file mode 100644 index 0000000..a3593b3 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/PrismaIDBClient.ts @@ -0,0 +1,87 @@ +import { ClassDeclaration, CodeBlockWriter, Scope, SourceFile } from "ts-morph"; +import { getUniqueIdentifiers, toCamelCase } from "../../../helpers/utils"; +import { Model } from "../../types"; + +export function addClientClass(file: SourceFile, models: readonly Model[]) { + const clientClass = file.addClass({ + name: "PrismaIDBClient", + isExported: true, + ctors: [{ scope: Scope.Private }], + properties: [ + { name: "instance", isStatic: true, type: "PrismaIDBClient", scope: Scope.Private }, + { name: "_db", type: "IDBPDatabase", hasExclamationToken: true }, + ], + }); + + addModelProperties(clientClass, models); + addCreateInstanceMethod(clientClass); + addInitializeMethod(clientClass, models); +} + +function addModelProperties(clientClass: ClassDeclaration, models: readonly Model[]) { + models.forEach((model) => + clientClass.addProperty({ + name: toCamelCase(model.name), + type: `${model.name}IDBClass`, + hasExclamationToken: true, + }), + ); +} + +function addCreateInstanceMethod(clientClass: ClassDeclaration) { + clientClass.addMethod({ + name: "create", + isStatic: true, + isAsync: true, + scope: Scope.Public, + returnType: "Promise", + statements: (writer) => { + writer + .writeLine("if (!PrismaIDBClient.instance)") + .block(() => { + writer + .writeLine("const client = new PrismaIDBClient();") + .writeLine("await client.initialize();") + .writeLine("PrismaIDBClient.instance = client;"); + }) + .writeLine("return PrismaIDBClient.instance;"); + }, + }); +} + +function addInitializeMethod(clientClass: ClassDeclaration, models: readonly Model[]) { + clientClass.addMethod({ + name: "initialize", + scope: Scope.Private, + isAsync: true, + statements: (writer) => { + writer + .writeLine("this._db = await openDB('prisma-idb', IDB_VERSION, ") + .block(() => { + writer.writeLine("upgrade(db)").block(() => { + models.forEach((model) => addObjectStoreInitialization(model, writer)); + }); + }) + .writeLine(");"); + + models.forEach((model) => { + writer.writeLine( + `this.${toCamelCase(model.name)} = new ${model.name}IDBClass(this, ${getUniqueIdentifiers(model)[0].keyPath});`, + ); + }); + }, + }); +} + +function addObjectStoreInitialization(model: Model, writer: CodeBlockWriter) { + const nonKeyUniqueIdentifiers = getUniqueIdentifiers(model).slice(1); + const keyPath = getUniqueIdentifiers(model)[0].keyPath; + + let declarationLine = nonKeyUniqueIdentifiers.length ? `const ${model.name}Store = ` : ``; + declarationLine += `db.createObjectStore('${model.name}', { keyPath: ${keyPath} });`; + + writer.writeLine(declarationLine); + nonKeyUniqueIdentifiers.forEach(({ name, keyPath }) => + writer.writeLine(`${model.name}Store.createIndex("${name}Index", ${keyPath}, { unique: true });`), + ); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/IDBModelClass.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/IDBModelClass.ts new file mode 100644 index 0000000..ecdf9a0 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/IDBModelClass.ts @@ -0,0 +1,44 @@ +import { SourceFile } from "ts-morph"; +import { Model } from "../../../types"; +import { addCountMethod } from "./api/count"; +import { addCreateMethod } from "./api/create"; +import { addFindFirstMethod } from "./api/findFirst"; +import { addFindManyMethod } from "./api/findMany"; +import { addFindUniqueMethod } from "./api/findUnique"; +import { addApplyRelations } from "./utils/_applyRelations"; +import { addApplySelectClause } from "./utils/_applySelectClause"; +import { addFillDefaultsFunction } from "./utils/_fillDefaults"; +import { addGetNeededStoresForCreate } from "./utils/_getNeededStoresForCreate"; +import { addNestedCreateMethod } from "./utils/_nestedCreate"; +import { addPerformNestedCreatesMethod } from "./utils/_performNestedCreates"; +import { addRemoveNestedCreateDataMethod } from "./utils/_removeNestedCreateData"; +import { addFindFirstOrThrow } from "./api/findFirstOrThrow"; +import { addApplyWhereClause } from "./utils/_applyWhereClause"; +import { addCreateManyMethod } from "./api/createMany"; +import { addFindUniqueOrThrow } from "./api/findUniqueOrThrow"; + +export function addIDBModelClass(file: SourceFile, model: Model, models: readonly Model[]) { + const modelClass = file.addClass({ + name: `${model.name}IDBClass`, + extends: "BaseIDBModelClass", + }); + + addApplyWhereClause(modelClass, model); + addApplySelectClause(modelClass, model); + addApplyRelations(modelClass, model, models); + addFillDefaultsFunction(modelClass, model); + addGetNeededStoresForCreate(modelClass, model); + addRemoveNestedCreateDataMethod(modelClass, model); + addPerformNestedCreatesMethod(modelClass, model, models); + addNestedCreateMethod(modelClass, model); + + addFindManyMethod(modelClass, model); + addFindFirstMethod(modelClass, model); + addFindFirstOrThrow(modelClass, model); + addFindUniqueMethod(modelClass, model); + addFindUniqueOrThrow(modelClass, model); + addCountMethod(modelClass, model); + + addCreateMethod(modelClass, model); + addCreateManyMethod(modelClass, model); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/count.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/count.ts new file mode 100644 index 0000000..c7619f5 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/count.ts @@ -0,0 +1,42 @@ +import { Model } from "../../../../../fileCreators/types"; +import { ClassDeclaration, CodeBlockWriter } from "ts-morph"; + +export function addCountMethod(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "count", + isAsync: true, + typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], + parameters: [{ name: "query", hasQuestionToken: true, type: "Q" }], + returnType: `Promise>`, + statements: (writer) => { + handleWithoutSelect(writer, model); + handleWithSelect(writer, model); + }, + }); +} + +function handleWithoutSelect(writer: CodeBlockWriter, model: Model) { + writer.writeLine(`if (!query?.select || query.select === true)`).block(() => { + writer + .writeLine(`const records = await this.findMany({ where: query?.where });`) + .writeLine(`return records.length as Prisma.Result;`); + }); +} + +function handleWithSelect(writer: CodeBlockWriter, model: Model) { + writer + .writeLine(`const result: Partial> = {};`) + .writeLine(`for (const key of Object.keys(query.select))`) + .block(() => { + writer + .writeLine(`const typedKey = key as keyof typeof query.select;`) + .writeLine(`if (typedKey === "_all")`) + .block(() => { + writer + .writeLine(`result[typedKey] = (await this.findMany({ where: query.where })).length;`) + .writeLine(`continue;`); + }) + .writeLine("result[typedKey] = (await this.findMany({ where: { [`${typedKey}`]: { not: null } } })).length;"); + }) + .writeLine(`return result as Prisma.Result;`); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/create.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/create.ts new file mode 100644 index 0000000..dd97b1c --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/create.ts @@ -0,0 +1,55 @@ +import { ClassDeclaration, CodeBlockWriter } from "ts-morph"; +import { Model } from "../../../../../fileCreators/types"; + +// TODO: referential integrity? +// TODO: nested creates, connect, connectOrCreate + +export function addCreateMethod(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "create", + isAsync: true, + typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], + parameters: [{ name: "query", type: "Q" }], + returnType: `Promise>`, + statements: (writer) => { + fillDefaults(writer, model); + addTransactionalHandling(writer, model); + applyClausesAndReturnRecords(writer, model); + }, + }); +} + +function fillDefaults(writer: CodeBlockWriter, model: Model) { + writer + .writeLine("const record = await this._fillDefaults(query.data);") + .writeLine(`let keyPath: PrismaIDBSchema['${model.name}']['key']`); +} + +function applyClausesAndReturnRecords(writer: CodeBlockWriter, model: Model) { + writer + .writeLine(`const data = (await this.client._db.get("${model.name}", keyPath))!;`) + .write(`const recordsWithRelations = this._applySelectClause`) + .write(`(await this._applyRelations([data], query), query.select)[0];`); + + writer.writeLine(`return recordsWithRelations as Prisma.Result;`); +} + +function addTransactionalHandling(writer: CodeBlockWriter, model: Model) { + writer + .writeLine(`const storesNeeded = this._getNeededStoresForCreate(query.data);`) + .writeLine(`if (storesNeeded.size === 0)`) + .block(() => { + writer.writeLine(`keyPath = await this.client._db.add("${model.name}", record);`); + }) + .writeLine(`else`) + .block(() => { + writer + .writeLine(`const tx = this.client._db.transaction(`) + .writeLine(`["${model.name}", ...Array.from(storesNeeded)],`) + .writeLine(`"readwrite"`) + .writeLine(`);`) + .writeLine(`await this._performNestedCreates(query.data, tx);`) + .writeLine(`keyPath = await tx.objectStore("${model.name}").add(this._removeNestedCreateData(query.data));`) + .writeLine(`tx.commit();`); + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/createMany.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/createMany.ts new file mode 100644 index 0000000..8342e85 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/createMany.ts @@ -0,0 +1,40 @@ +import { ClassDeclaration, CodeBlockWriter } from "ts-morph"; +import { Model } from "../../../../../fileCreators/types"; + +// TODO: skipDuplicates + +export function addCreateManyMethod(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "createMany", + isAsync: true, + typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], + parameters: [ + { name: "query", type: "Q" }, + { name: "tx", hasQuestionToken: true, type: "CreateTransactionType" }, + ], + returnType: `Promise>`, + statements: (writer) => { + setupDataAndTx(writer, model); + addTransactionalHandling(writer, model); + returnCount(writer); + }, + }); +} + +function setupDataAndTx(writer: CodeBlockWriter, model: Model) { + writer + .writeLine("const createManyData = convertToArray(query.data);") + .writeLine(`tx = tx ?? this.client._db.transaction(["${model.name}"], "readwrite");`); +} + +function addTransactionalHandling(writer: CodeBlockWriter, model: Model) { + writer.writeLine(`for (const createData of createManyData)`).block(() => { + writer + .writeLine(`const record = await this._fillDefaults(createData, tx);`) + .writeLine(`await tx.objectStore("${model.name}").add(record);`); + }); +} + +function returnCount(writer: CodeBlockWriter) { + writer.writeLine(`return { count: createManyData.length };`); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findFirst.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findFirst.ts new file mode 100644 index 0000000..fa8108e --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findFirst.ts @@ -0,0 +1,15 @@ +import { Model } from "../../../../../fileCreators/types"; +import { ClassDeclaration } from "ts-morph"; + +export function addFindFirstMethod(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "findFirst", + isAsync: true, + typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], + parameters: [{ name: "query", hasQuestionToken: true, type: "Q" }], + returnType: `Promise>`, + statements: (writer) => { + writer.writeLine(`return (await this.findMany(query))[0];`); + }, + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findFirstOrThrow.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findFirstOrThrow.ts new file mode 100644 index 0000000..417eba4 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findFirstOrThrow.ts @@ -0,0 +1,18 @@ +import { Model } from "../../../../../fileCreators/types"; +import { ClassDeclaration } from "ts-morph"; + +export function addFindFirstOrThrow(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "findFirstOrThrow", + isAsync: true, + typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], + parameters: [{ name: "query", hasQuestionToken: true, type: "Q" }], + returnType: `Promise>`, + statements: (writer) => { + writer + .writeLine(`const record = await this.findFirst(query);`) + .writeLine(`if (!record) throw new Error("Record not found");`) + .writeLine(`return record;`); + }, + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findMany.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findMany.ts new file mode 100644 index 0000000..07d45e6 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findMany.ts @@ -0,0 +1,40 @@ +import { Model } from "../../../../../fileCreators/types"; +import { ClassDeclaration, CodeBlockWriter } from "ts-morph"; + +export function addFindManyMethod(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "findMany", + isAsync: true, + typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], + parameters: [{ name: "query", hasQuestionToken: true, type: "Q" }], + returnType: `Promise>`, + statements: (writer) => { + getRecords(writer, model); + applyRelationsToRecords(writer, model); + applySelectClauseToRecords(writer); + returnRecords(writer, model); + }, + }); +} + +function getRecords(writer: CodeBlockWriter, model: Model) { + writer.writeLine( + `const records = await this._applyWhereClause(await this.client._db.getAll("${model.name}"), query?.where)`, + ); +} + +function applyRelationsToRecords(writer: CodeBlockWriter, model: Model) { + writer + .write(`const relationAppliedRecords = (await this._applyRelations(records, query)) `) + .write(`as Prisma.Result[];`); +} + +function applySelectClauseToRecords(writer: CodeBlockWriter) { + writer + .writeLine("const selectClause = query?.select;") + .writeLine("const selectAppliedRecords = this._applySelectClause(relationAppliedRecords, selectClause);"); +} + +function returnRecords(writer: CodeBlockWriter, model: Model) { + writer.writeLine(`return selectAppliedRecords as Prisma.Result;`); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findUnique.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findUnique.ts new file mode 100644 index 0000000..bbe9860 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findUnique.ts @@ -0,0 +1,65 @@ +import { ClassDeclaration, CodeBlockWriter } from "ts-morph"; +import { Model } from "../../../../../fileCreators/types"; +import { getUniqueIdentifiers } from "../../../../../helpers/utils"; + +export function addFindUniqueMethod(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "findUnique", + isAsync: true, + typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], + parameters: [{ name: "query", type: "Q" }], + returnType: `Promise>`, + statements: (writer) => { + writer.writeLine("let record;"); + getFromKeyIdentifier(writer, model); + getFromNonKeyIdentifier(writer, model); + writer + .writeLine("if (!record) return null;") + .blankLine() + .write(`const recordWithRelations = `) + .write( + `this._applySelectClause(await this._applyRelations(await this._applyWhereClause([record], query.where), query), query.select)[0];`, + ) + .writeLine(`return recordWithRelations as Prisma.Result;`); + }, + }); +} + +function getFromKeyIdentifier(writer: CodeBlockWriter, model: Model) { + const keyUniqueIdentifier = getUniqueIdentifiers(model)[0]; + const fieldNames = JSON.parse(keyUniqueIdentifier.keyPath) as string[]; + + let fields: string; + if (fieldNames.length === 1) { + fields = JSON.stringify(fieldNames.map((fieldName: string) => `query.where.${fieldName}`)); + } else { + fields = JSON.stringify( + fieldNames.map((fieldName: string) => `query.where.${keyUniqueIdentifier.name}.${fieldName}`), + ); + } + fields = fields.replaceAll('"', ""); + + writer.writeLine(`if (query.where.${keyUniqueIdentifier.name})`).block(() => { + writer.write(`record = await this.client._db.get('${model.name}', ${fields})`); + }); +} + +function getFromNonKeyIdentifier(writer: CodeBlockWriter, model: Model) { + const nonKeyUniqueIdentifiers = getUniqueIdentifiers(model).slice(1); + + nonKeyUniqueIdentifiers.forEach(({ name, keyPath }) => { + const fieldNames = JSON.parse(keyPath) as string[]; + + let fields: string; + if (fieldNames.length === 1) { + fields = JSON.stringify(fieldNames.map((fieldName: string) => `query.where.${fieldName}`)); + } else { + fields = JSON.stringify(fieldNames.map((fieldName: string) => `query.where.${name}.${fieldName}`)); + } + fields = fields.replaceAll('"', ""); + + writer.writeLine(`else if (query.where.${name})`).block(() => { + writer.write(`record = await this.client._db.getFromIndex`).write(`('${model.name}', '${name}Index', ${fields})`); + }); + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findUniqueOrThrow.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findUniqueOrThrow.ts new file mode 100644 index 0000000..d10a7e7 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/api/findUniqueOrThrow.ts @@ -0,0 +1,18 @@ +import { Model } from "../../../../../fileCreators/types"; +import { ClassDeclaration } from "ts-morph"; + +export function addFindUniqueOrThrow(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "findUniqueOrThrow", + isAsync: true, + typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], + parameters: [{ name: "query", type: "Q" }], + returnType: `Promise>`, + statements: (writer) => { + writer + .writeLine(`const record = await this.findUnique(query);`) + .writeLine(`if (!record) throw new Error("Record not found");`) + .writeLine(`return record;`); + }, + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_applyRelations.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_applyRelations.ts new file mode 100644 index 0000000..c601db4 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_applyRelations.ts @@ -0,0 +1,104 @@ +import { Field, Model } from "../../../../types"; +import { toCamelCase } from "../../../../../helpers/utils"; +import { ClassDeclaration, CodeBlockWriter, Scope } from "ts-morph"; + +export function addApplyRelations(modelClass: ClassDeclaration, model: Model, models: readonly Model[]) { + modelClass.addMethod({ + name: "_applyRelations", + isAsync: true, + scope: Scope.Private, + typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], + parameters: [ + { name: "records", type: `Prisma.Result[]` }, + { name: "query", type: "Q", hasQuestionToken: true }, + ], + returnType: `Promise[]>`, + statements: (writer) => { + addEarlyExit(writer, model); + addRelationProcessing(writer, model, models); + addReturn(writer, model); + }, + }); +} + +function addEarlyExit(writer: CodeBlockWriter, model: Model) { + writer.writeLine( + `if (!query) return records as Prisma.Result[];`, + ); +} + +function addRelationProcessing(writer: CodeBlockWriter, model: Model, models: readonly Model[]) { + const relationFields = model.fields.filter(({ kind }) => kind === "object"); + const allFields = models.flatMap(({ fields }) => fields); + + writer + .writeLine("const recordsWithRelations = records.map(async (record) => ") + .block(() => { + writer.writeLine("const unsafeRecord = record as Record;"); + relationFields.forEach((field) => { + writer + .write(`const attach_${field.name} = `) + .write(`query.select?.${field.name} || query.include?.${field.name};`) + .writeLine(`if (attach_${field.name})`) + .block(() => { + const otherFieldOfRelation = allFields.find( + (_field) => _field.relationName === field.relationName && field !== _field, + )!; + handleVariousRelationships(writer, model, field, otherFieldOfRelation); + }); + }); + writer.writeLine("return unsafeRecord;"); + }) + .writeLine(");"); +} + +function handleVariousRelationships(writer: CodeBlockWriter, model: Model, field: Field, otherField: Field) { + if (!field.isList) { + if (field.isRequired) { + addOneToOneMetaOnFieldRelation(writer, field); + } else { + addOneToOneMetaOnOtherFieldRelation(writer, field, otherField); + } + } else { + addOneToManyRelation(writer, field, otherField); + } +} + +function addOneToOneMetaOnFieldRelation(writer: CodeBlockWriter, field: Field) { + writer + .writeLine(`unsafeRecord['${field.name}'] = await this.client.${toCamelCase(field.type)}.findUnique(`) + .block(() => { + writer + .writeLine(`...(attach_${field.name} === true ? {} : attach_${field.name}),`) + .writeLine(`where: { ${field.relationToFields?.at(0)}: record.${field.relationFromFields?.at(0)} }`); + }) + .writeLine(`)`); +} + +function addOneToOneMetaOnOtherFieldRelation(writer: CodeBlockWriter, field: Field, otherField: Field) { + writer + .writeLine(`unsafeRecord['${field.name}'] = await this.client.${toCamelCase(field.type)}.findUnique(`) + .block(() => { + writer + .writeLine(`...(attach_${field.name} === true ? {} : attach_${field.name}),`) + .writeLine(`where: { ${otherField.relationFromFields?.at(0)}: record.${otherField.relationToFields?.at(0)} }`); + }) + .writeLine(`)`); +} + +function addOneToManyRelation(writer: CodeBlockWriter, field: Field, otherField: Field) { + writer + .writeLine(`unsafeRecord['${field.name}'] = await this.client.${toCamelCase(field.type)}.findMany(`) + .block(() => { + writer + .writeLine(`...(attach_${field.name} === true ? {} : attach_${field.name}),`) + .writeLine(`where: { ${otherField.relationFromFields?.at(0)}: record.${otherField.relationToFields?.at(0)} }`); + }) + .writeLine(`)`); +} + +function addReturn(writer: CodeBlockWriter, model: Model) { + writer + .write(`return (await Promise.all(recordsWithRelations)) as `) + .write(`Prisma.Result[];`); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_applySelectClause.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_applySelectClause.ts new file mode 100644 index 0000000..0b8ff99 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_applySelectClause.ts @@ -0,0 +1,44 @@ +import { Model } from "src/fileCreators/types"; +import { ClassDeclaration, CodeBlockWriter, Scope } from "ts-morph"; + +export function addApplySelectClause(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "_applySelectClause", + scope: Scope.Private, + typeParameters: [{ name: "S", constraint: `Prisma.Args['select']` }], + parameters: [ + { name: "records", type: `Prisma.Result[]` }, + { name: "selectClause", type: "S" }, + ], + returnType: `Prisma.Result[]`, + statements: (writer) => { + addEarlyExit(writer, model); + addSelectProcessing(writer, model); + }, + }); +} + +function addEarlyExit(writer: CodeBlockWriter, model: Model) { + writer.writeLine("if (!selectClause)").block(() => { + writer.writeLine( + `return records as Prisma.Result[];`, + ); + }); +} + +function addSelectProcessing(writer: CodeBlockWriter, model: Model) { + writer + .writeLine("return records.map((record) => ") + .block(() => { + writer + .writeLine("const partialRecord: Partial = record;") + .writeLine(`for (const untypedKey of ${JSON.stringify(model.fields.map(({ name }) => name))}) `) + .block(() => { + writer + .writeLine("const key = untypedKey as keyof typeof record & keyof S;") + .writeLine("if (!selectClause[key]) delete partialRecord[key];"); + }) + .writeLine("return partialRecord;"); + }) + .writeLine(`) as Prisma.Result[];`); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_applyWhereClause.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_applyWhereClause.ts new file mode 100644 index 0000000..7401749 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_applyWhereClause.ts @@ -0,0 +1,107 @@ +import type { Model } from "src/fileCreators/types"; +import { ClassDeclaration, CodeBlockWriter, Scope } from "ts-morph"; + +export function addApplyWhereClause(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "_applyWhereClause", + isAsync: true, + scope: Scope.Private, + typeParameters: [ + { name: "W", constraint: `Prisma.Args['where']` }, + { name: "R", constraint: `Prisma.Result` }, + ], + parameters: [ + { name: "records", type: `R[]` }, + { name: "whereClause", type: "W" }, + ], + returnType: `Promise`, + statements: (writer) => { + writer.writeLine(`if (!whereClause) return records;`); + writer + .writeLine(`return records.filter((record) => `) + .block(() => { + addStringFiltering(writer, model); + addNumberFiltering(writer, model); + addBigIntFiltering(writer, model); + addBoolFiltering(writer, model); + addBytesFiltering(writer, model); + addDateTimeFiltering(writer, model); + writer.writeLine(`return true;`); + }) + .writeLine(`);`); + }, + }); +} + +function addStringFiltering(writer: CodeBlockWriter, model: Model) { + const stringFields = model.fields.filter((field) => field.type === "String").map(({ name }) => name); + if (stringFields.length === 0) return; + writer + .writeLine(`const stringFields = ${JSON.stringify(stringFields)} as const;`) + .writeLine(`for (const field of stringFields)`) + .block(() => { + writer.writeLine(`if (!whereStringFilter(record, field, whereClause[field])) return false;`); + }); +} + +function addNumberFiltering(writer: CodeBlockWriter, model: Model) { + const numberFields = model.fields + .filter((field) => field.type === "Int" || field.type === "Float") + .map(({ name }) => name); + + if (numberFields.length === 0) return; + writer + .writeLine(`const numberFields = ${JSON.stringify(numberFields)} as const;`) + .writeLine(`for (const field of numberFields)`) + .block(() => { + writer.writeLine(`if (!whereNumberFilter(record, field, whereClause[field])) return false;`); + }); +} + +function addBigIntFiltering(writer: CodeBlockWriter, model: Model) { + const numberFields = model.fields.filter((field) => field.type === "BigInt").map(({ name }) => name); + + if (numberFields.length === 0) return; + writer + .writeLine(`const bigIntFields = ${JSON.stringify(numberFields)} as const;`) + .writeLine(`for (const field of bigIntFields)`) + .block(() => { + writer.writeLine(`if (!whereBigIntFilter(record, field, whereClause[field])) return false;`); + }); +} + +function addBoolFiltering(writer: CodeBlockWriter, model: Model) { + const booleanFields = model.fields.filter((field) => field.type === "Boolean").map(({ name }) => name); + + if (booleanFields.length === 0) return; + writer + .writeLine(`const booleanFields = ${JSON.stringify(booleanFields)} as const;`) + .writeLine(`for (const field of booleanFields)`) + .block(() => { + writer.writeLine(`if (!whereBoolFilter(record, field, whereClause[field])) return false;`); + }); +} + +function addBytesFiltering(writer: CodeBlockWriter, model: Model) { + const bytesFields = model.fields.filter((field) => field.type === "Bytes").map(({ name }) => name); + + if (bytesFields.length === 0) return; + writer + .writeLine(`const bytesFields = ${JSON.stringify(bytesFields)} as const;`) + .writeLine(`for (const field of bytesFields)`) + .block(() => { + writer.writeLine(`if (!whereBytesFilter(record, field, whereClause[field])) return false;`); + }); +} + +function addDateTimeFiltering(writer: CodeBlockWriter, model: Model) { + const dateTimeFields = model.fields.filter((field) => field.type === "DateTime").map(({ name }) => name); + + if (dateTimeFields.length === 0) return; + writer + .writeLine(`const dateTimeFields = ${JSON.stringify(dateTimeFields)} as const;`) + .writeLine(`for (const field of dateTimeFields)`) + .block(() => { + writer.writeLine(`if (!whereDateTimeFilter(record, field, whereClause[field])) return false;`); + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_fillDefaults.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_fillDefaults.ts new file mode 100644 index 0000000..d96b33a --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_fillDefaults.ts @@ -0,0 +1,98 @@ +import { Field, Model } from "src/fileCreators/types"; +import { ClassDeclaration, CodeBlockWriter, Scope } from "ts-morph"; + +export function addFillDefaultsFunction(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "_fillDefaults", + isAsync: true, + scope: Scope.Private, + typeParameters: [{ name: "D", constraint: `Prisma.Args["data"]` }], + parameters: [ + { name: "data", type: "D" }, + { name: "tx", hasQuestionToken: true, type: "CreateTransactionType" }, + ], + returnType: `Promise>`, + statements: (writer) => { + writer.writeLine("if (data === undefined) data = {} as NonNullable;"); + model.fields + .filter(({ kind }) => kind !== "object") + .filter(({ hasDefaultValue, isRequired }) => hasDefaultValue || !isRequired) + .forEach((field) => { + writer.writeLine(`if (data.${field.name} === undefined) `).block(() => { + if (typeof field.default === "object" && "name" in field.default) { + if (field.default.name === "uuid(4)") { + addUuidDefault(writer, field); + } else if (field.default.name === "cuid") { + addCuidDefault(writer, field); + } else if (field.default.name === "autoincrement") { + addAutoincrementDefault(writer, model, field); + } else if (field.default.name === "now") { + addNowDefault(writer, field); + } + } else if (field.default) { + addDefaultValue(writer, field); + } else if (!field.isRequired) { + addNullAssignment(writer, field); + } + }); + }); + model.fields.forEach((field) => { + if (field.type === "DateTime") { + addDateStringToDateConverter(writer, field); + } + if (field.type === "BigInt") { + addBigIntConverter(writer, field); + } + }); + writer.writeLine(`return data as Prisma.Result;`); + }, + }); +} + +function addUuidDefault(writer: CodeBlockWriter, field: Field) { + writer.writeLine(`data.${field.name} = crypto.randomUUID();`); +} + +function addCuidDefault(writer: CodeBlockWriter, field: Field) { + writer + .writeLine("const { createId } = await import('@paralleldrive/cuid2');") + .writeLine(`data.${field.name} = createId();`); +} + +function addAutoincrementDefault(writer: CodeBlockWriter, model: Model, field: Field) { + writer + .write(`const transaction = tx ?? this.client._db.transaction(["${model.name}"], "readwrite");`) + .write(`const store = transaction.objectStore('${model.name}');`) + .write("const cursor = await store.openCursor(null, 'prev');") + .write(`data.${field.name} = (cursor ? Number(cursor.key) + 1 : 1);`); +} + +function addDefaultValue(writer: CodeBlockWriter, field: Field) { + if (field.isList) { + writer.write(`data.${field.name} = ${JSON.stringify(field.default)};`); + } else if (field.type === "String") { + writer.write(`data.${field.name} = '${field.default}';`); + } else { + writer.write(`data.${field.name} = ${field.default};`); + } +} + +function addNullAssignment(writer: CodeBlockWriter, field: Field) { + writer.write(`data.${field.name} = null;`); +} + +function addNowDefault(writer: CodeBlockWriter, field: Field) { + writer.writeLine(`data.${field.name} = new Date();`); +} + +function addDateStringToDateConverter(writer: CodeBlockWriter, field: Field) { + writer.writeLine(`if (typeof data.${field.name} === 'string')`).block(() => { + writer.writeLine(`data.${field.name} = new Date(data.${field.name})`); + }); +} + +function addBigIntConverter(writer: CodeBlockWriter, field: Field) { + writer.writeLine(`if (typeof data.${field.name} === 'number')`).block(() => { + writer.writeLine(`data.${field.name} = BigInt(data.${field.name})`); + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_getNeededStoresForCreate.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_getNeededStoresForCreate.ts new file mode 100644 index 0000000..0c2feaa --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_getNeededStoresForCreate.ts @@ -0,0 +1,49 @@ +import { Model } from "src/fileCreators/types"; +import { toCamelCase } from "../../../../../helpers/utils"; +import { ClassDeclaration, CodeBlockWriter } from "ts-morph"; + +export function addGetNeededStoresForCreate(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "_getNeededStoresForCreate", + typeParameters: [{ name: "D", constraint: `Partial['data']>` }], + parameters: [{ name: "data", type: "D" }], + returnType: "Set>", + statements: (writer) => { + writer.writeLine("const neededStores: Set> = new Set();"); + processRelationsInData(writer, model); + writer.writeLine("return neededStores;"); + }, + }); +} + +function processRelationsInData(writer: CodeBlockWriter, model: Model) { + const relationFields = model.fields.filter(({ kind }) => kind === "object"); + relationFields.forEach((field) => { + writer.writeLine(`if (data.${field.name})`).block(() => { + writer.writeLine(`neededStores.add('${field.type}')`); + writer.writeLine(`if (data.${field.name}.create)`).block(() => { + writer + .writeLine(`convertToArray(data.${field.name}.create).forEach((record) => `) + .write(`this.client.${toCamelCase(field.type)}._getNeededStoresForCreate(record)`) + .write(`.forEach((storeName) => neededStores.add(storeName))`) + .writeLine(`);`); + }); + writer.writeLine(`if (data.${field.name}.connectOrCreate)`).block(() => { + writer + .writeLine(`convertToArray(data.${field.name}.connectOrCreate).forEach((record) => `) + .write(`this.client.${toCamelCase(field.type)}._getNeededStoresForCreate(record.create)`) + .write(`.forEach((storeName) => neededStores.add(storeName))`) + .writeLine(`);`); + }); + if (field.isList) { + writer.writeLine(`if (data.${field.name}.createMany)`).block(() => { + writer + .writeLine(`convertToArray(data.${field.name}.createMany.data).forEach((record) => `) + .write(`this.client.${toCamelCase(field.type)}._getNeededStoresForCreate(record)`) + .write(`.forEach((storeName) => neededStores.add(storeName))`) + .writeLine(`);`); + }); + } + }); + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_nestedCreate.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_nestedCreate.ts new file mode 100644 index 0000000..1ace35a --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_nestedCreate.ts @@ -0,0 +1,22 @@ +import { Model } from "../../../../types"; +import { ClassDeclaration } from "ts-morph"; + +export function addNestedCreateMethod(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + name: "_nestedCreate", + isAsync: true, + typeParameters: [{ name: "Q", constraint: `Prisma.Args` }], + parameters: [ + { name: "query", type: "Q" }, + { name: "tx", type: "CreateTransactionType" }, + ], + returnType: `Promise`, + statements: (writer) => { + writer + .writeLine(`await this._performNestedCreates(query.data, tx);`) + .writeLine(`const record = await this._fillDefaults(query.data, tx);`) + .writeLine(`const keyPath = await tx.objectStore("${model.name}").add(record);`) + .writeLine(`return keyPath;`); + }, + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_performNestedCreates.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_performNestedCreates.ts new file mode 100644 index 0000000..fd7ce0a --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_performNestedCreates.ts @@ -0,0 +1,122 @@ +import { ClassDeclaration, CodeBlockWriter, Scope } from "ts-morph"; +import { Field, Model } from "../../../../types"; +import { toCamelCase } from "../../../../../helpers/utils"; + +// TODO: handle composite keyPaths, oneToMany, createMany +// TODO: connect and then, connectOrCreate + +export function addPerformNestedCreatesMethod(modelClass: ClassDeclaration, model: Model, models: readonly Model[]) { + modelClass.addMethod({ + scope: Scope.Private, + name: "_performNestedCreates", + isAsync: true, + typeParameters: [{ name: "D", constraint: `Prisma.Args["data"]` }], + parameters: [ + { name: "data", type: "D" }, + { name: "tx", type: "CreateTransactionType" }, + ], + returnType: ``, + statements: (writer) => { + addRelationProcessing(writer, model, models); + }, + }); +} + +function addRelationProcessing(writer: CodeBlockWriter, model: Model, models: readonly Model[]) { + const relationFields = model.fields.filter(({ kind }) => kind === "object"); + const allFields = models.flatMap(({ fields }) => fields); + + relationFields.forEach((field) => { + const otherFieldOfRelation = allFields.find( + (_field) => _field.relationName === field.relationName && field !== _field, + )!; + + writer.writeLine(`if (data.${field.name})`).block(() => { + handleVariousRelationships(writer, model, field, otherFieldOfRelation); + }); + }); +} + +function handleVariousRelationships(writer: CodeBlockWriter, model: Model, field: Field, otherField: Field) { + if (!field.isList) { + if (field.isRequired) { + addOneToOneMetaOnFieldRelation(writer, field); + } else { + addOneToOneMetaOnOtherFieldRelation(writer, field, otherField); + } + } else { + addOneToManyRelation(writer, field, otherField); + } +} + +function addOneToOneMetaOnFieldRelation(writer: CodeBlockWriter, field: Field) { + writer + .writeLine(`let fk;`) + .writeLine(`if (data.${field.name}.create)`) + .block(() => { + writer.writeLine( + `fk = (await this.client.${toCamelCase(field.type)}._nestedCreate({ data: data.${field.name}.create }, tx))[0];`, + ); + }); + writer.writeLine(`if (data.${field.name}.connectOrCreate)`).block(() => { + writer.writeLine(`throw new Error('connectOrCreate not yet implemented')`); + }); + writer + .writeLine(`const unsafeData = data as Record;`) + .writeLine(`unsafeData.userId = fk as NonNullable;`) + .writeLine(`delete unsafeData.${field.name};`); +} + +function addOneToOneMetaOnOtherFieldRelation(writer: CodeBlockWriter, field: Field, otherField: Field) { + writer.writeLine(`if (data.${field.name}.create)`).block(() => { + writer + .write(`await this.client.${toCamelCase(field.type)}._nestedCreate(`) + .block(() => { + writer.writeLine( + `data: { ...data.${field.name}.create, ${otherField.relationFromFields?.at(0)}: data.${otherField.relationToFields?.at(0)}! }`, + ); + }) + .writeLine(`, tx)`); + }); + writer.writeLine(`if (data.${field.name}.connectOrCreate)`).block(() => { + writer.writeLine(`throw new Error('connectOrCreate not yet implemented')`); + }); + writer.writeLine(`delete data.${field.name};`); +} + +function addOneToManyRelation(writer: CodeBlockWriter, field: Field, otherField: Field) { + writer.writeLine(`if (data.${field.name}.create)`).block(() => { + writer + .write(`await this.client.${toCamelCase(field.type)}.createMany(`) + .block(() => { + writer + .writeLine(`data: convertToArray(data.${field.name}.create).map((createData) => (`) + .block(() => { + writer.writeLine( + `...createData, ${otherField.relationFromFields?.at(0)}: data.${otherField.relationToFields?.at(0)}!`, + ); + }) + .writeLine(`)),`); + }) + .writeLine(`, tx)`); + }); + writer.writeLine(`if (data.${field.name}.connectOrCreate)`).block(() => { + writer.writeLine(`throw new Error('connectOrCreate not yet implemented')`); + }); + writer.writeLine(`if (data.${field.name}.createMany)`).block(() => { + writer + .write(`await this.client.${toCamelCase(field.type)}.createMany(`) + .block(() => { + writer + .writeLine(`data: convertToArray(data.${field.name}.createMany.data).map((createData) => (`) + .block(() => { + writer.writeLine( + `...createData, ${otherField.relationFromFields?.at(0)}: data.${otherField.relationToFields?.at(0)}!`, + ); + }) + .writeLine(`)),`); + }) + .writeLine(`, tx)`); + }); + writer.writeLine(`delete data.${field.name};`); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_removeNestedCreateData.ts b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_removeNestedCreateData.ts new file mode 100644 index 0000000..71466c1 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/classes/models/utils/_removeNestedCreateData.ts @@ -0,0 +1,26 @@ +import { ClassDeclaration, CodeBlockWriter, Scope } from "ts-morph"; +import { Model } from "../../../../types"; + +export function addRemoveNestedCreateDataMethod(modelClass: ClassDeclaration, model: Model) { + modelClass.addMethod({ + scope: Scope.Private, + name: "_removeNestedCreateData", + typeParameters: [{ name: "D", constraint: `Prisma.Args["data"]` }], + parameters: [{ name: "data", type: "D" }], + returnType: `Prisma.Result`, + statements: (writer) => { + writer.writeLine(`const recordWithoutNestedCreate = structuredClone(data);`); + addRelationProcessing(writer, model); + writer.writeLine( + `return recordWithoutNestedCreate as Prisma.Result;`, + ); + }, + }); +} + +function addRelationProcessing(writer: CodeBlockWriter, model: Model) { + const relationFields = model.fields.filter(({ kind }) => kind === "object"); + relationFields.forEach((field) => { + writer.writeLine(`delete recordWithoutNestedCreate.${field.name};`); + }); +} diff --git a/packages/generator/src/fileCreators/prisma-idb-client/create.ts b/packages/generator/src/fileCreators/prisma-idb-client/create.ts new file mode 100644 index 0000000..f304541 --- /dev/null +++ b/packages/generator/src/fileCreators/prisma-idb-client/create.ts @@ -0,0 +1,56 @@ +import { DMMF } from "@prisma/generator-helper"; +import { SourceFile, VariableDeclarationKind } from "ts-morph"; +import { addClientClass } from "./classes/PrismaIDBClient"; +import { addBaseModelClass } from "./classes/BaseIDBModelClass"; +import { addIDBModelClass } from "./classes/models/IDBModelClass"; + +function addImports(file: SourceFile) { + file.addImportDeclaration({ + moduleSpecifier: "idb", + namedImports: ["openDB"], + trailingTrivia: (writer) => writer.writeLine("/* eslint-disable @typescript-eslint/no-unused-vars */"), + }); + file.addImportDeclaration({ + moduleSpecifier: "idb", + namedImports: ["IDBPDatabase", "IDBPTransaction", "StoreNames"], + isTypeOnly: true, + }); + file.addImportDeclaration({ moduleSpecifier: "@prisma/client", namedImports: ["Prisma"], isTypeOnly: true }); + file.addImportDeclaration({ + moduleSpecifier: "./idb-utils", + namedImports: [ + "convertToArray", + "whereStringFilter", + "whereNumberFilter", + "whereBigIntFilter", + "whereBoolFilter", + "whereBytesFilter", + "whereDateTimeFilter", + ], + }); + file.addImportDeclaration({ + moduleSpecifier: "./idb-utils", + namedImports: ["CreateTransactionType"], + isTypeOnly: true, + }); + file.addImportDeclaration({ + moduleSpecifier: "./idb-interface", + namedImports: ["PrismaIDBSchema"], + isTypeOnly: true, + }); +} + +function addVersionDeclaration(file: SourceFile) { + file.addVariableStatement({ + declarationKind: VariableDeclarationKind.Const, + declarations: [{ name: "IDB_VERSION", initializer: "1" }], + }); +} + +export function createPrismaIDBClientFile(idbClientFile: SourceFile, models: DMMF.Datamodel["models"]) { + addImports(idbClientFile); + addVersionDeclaration(idbClientFile); + addClientClass(idbClientFile, models); + addBaseModelClass(idbClientFile); + models.forEach((model) => addIDBModelClass(idbClientFile, model, models)); +} diff --git a/packages/generator/src/fileCreators/types.ts b/packages/generator/src/fileCreators/types.ts new file mode 100644 index 0000000..c81f63b --- /dev/null +++ b/packages/generator/src/fileCreators/types.ts @@ -0,0 +1,10 @@ +import { DMMF, ReadonlyDeep } from "@prisma/generator-helper"; + +export type Model = DMMF.Datamodel["models"][number]; + +export type Field = Model["fields"][number]; + +export type FunctionalDefaultValue = ReadonlyDeep<{ + name: string; + args: unknown[]; +}>; diff --git a/packages/generator/src/fileCreators/utils/create.ts b/packages/generator/src/fileCreators/utils/create.ts new file mode 100644 index 0000000..47a45e2 --- /dev/null +++ b/packages/generator/src/fileCreators/utils/create.ts @@ -0,0 +1,37 @@ +import { SourceFile } from "ts-morph"; +import { addStringFilter } from "./filters/StringFilter"; +import { addNumberFilter } from "./filters/NumberFilter"; +import { addBigIntFilter } from "./filters/BigIntFilter"; +import { addBoolFilter } from "./filters/BoolFilter"; +import { addBytesFilter } from "./filters/BytesFilter"; +import { addDateTimeFilter } from "./filters/DateTimeFilter"; + +export function createUtilsFile(idbUtilsFile: SourceFile) { + idbUtilsFile.addImportDeclarations([ + { moduleSpecifier: "idb", isTypeOnly: true, namedImports: ["IDBPTransaction", "StoreNames"] }, + { moduleSpecifier: "./idb-interface", isTypeOnly: true, namedImports: ["PrismaIDBSchema"] }, + { moduleSpecifier: "@prisma/client", isTypeOnly: true, namedImports: ["Prisma"] }, + ]); + + idbUtilsFile.addFunction({ + name: "convertToArray", + typeParameters: [{ name: "T" }], + parameters: [{ name: "arg", type: "T | T[]" }], + returnType: "T[]", + isExported: true, + statements: (writer) => writer.writeLine("return Array.isArray(arg) ? arg : [arg];"), + }); + + idbUtilsFile.addTypeAlias({ + isExported: true, + name: "CreateTransactionType", + type: `IDBPTransaction[], "readwrite">;`, + }); + + addStringFilter(idbUtilsFile); + addNumberFilter(idbUtilsFile); + addBigIntFilter(idbUtilsFile); + addBoolFilter(idbUtilsFile); + addBytesFilter(idbUtilsFile); + addDateTimeFilter(idbUtilsFile); +} diff --git a/packages/generator/src/fileCreators/utils/filters/BigIntFilter.ts b/packages/generator/src/fileCreators/utils/filters/BigIntFilter.ts new file mode 100644 index 0000000..510a977 --- /dev/null +++ b/packages/generator/src/fileCreators/utils/filters/BigIntFilter.ts @@ -0,0 +1,106 @@ +import type { CodeBlockWriter, SourceFile } from "ts-morph"; + +export function addBigIntFilter(utilsFile: SourceFile) { + utilsFile.addFunction({ + name: "whereBigIntFilter", + isExported: true, + typeParameters: [{ name: "T" }, { name: "R", constraint: `Prisma.Result` }], + parameters: [ + { name: "record", type: `R` }, + { name: "fieldName", type: "keyof R" }, + { + name: "bigIntFilter", + type: "Prisma.BigIntFilter | number | bigint | undefined | null", + }, + ], + returnType: "boolean", + statements: (writer) => { + writer + .writeLine(`if (bigIntFilter === undefined) return true;`) + .blankLine() + .writeLine(`const value = record[fieldName] as number | null;`) + .writeLine(`if (bigIntFilter === null) return value === null;`) + .blankLine() + .writeLine(`if (typeof bigIntFilter === 'number' || typeof bigIntFilter === 'bigint')`) + .block(() => { + writer.writeLine(`if (value !== bigIntFilter) return false;`); + }) + .writeLine(`else`) + .block(() => { + addEqualsHandler(writer); + addNotHandler(writer); + addInHandler(writer); + addNotInHandler(writer); + addLtHandler(writer); + addLteHandler(writer); + addGtHandler(writer); + addGteHandler(writer); + }) + .writeLine(`return true;`); + }, + }); +} + +function addEqualsHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (bigIntFilter.equals === null)`) + .block(() => { + writer.writeLine(`if (value !== null) return false;`); + }) + .writeLine(`if (typeof bigIntFilter.equals === "number" || typeof bigIntFilter.equals === "bigint")`) + .block(() => { + writer.writeLine(`if (bigIntFilter.equals != value) return false;`); + }); +} + +function addNotHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (bigIntFilter.not === null)`) + .block(() => { + writer.writeLine(`if (value === null) return false;`); + }) + .writeLine(`if (typeof bigIntFilter.not === "number" || typeof bigIntFilter.not === "bigint")`) + .block(() => { + writer.writeLine(`if (bigIntFilter.not == value) return false;`); + }); +} + +function addInHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (Array.isArray(bigIntFilter.in))`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (!bigIntFilter.in.map((n) => BigInt(n)).includes(BigInt(value))) return false;`); + }); +} + +function addNotInHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (Array.isArray(bigIntFilter.notIn))`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (bigIntFilter.notIn.map((n) => BigInt(n)).includes(BigInt(value))) return false;`); + }); +} + +function addLtHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof bigIntFilter.lt === "number" || typeof bigIntFilter.lt === "bigint")`).block(() => { + writer.writeLine(`if (value === null) return false;`).writeLine(`if (!(value < bigIntFilter.lt)) return false;`); + }); +} + +function addLteHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof bigIntFilter.lte === "number" || typeof bigIntFilter.lte === "bigint")`).block(() => { + writer.writeLine(`if (value === null) return false;`).writeLine(`if (!(value <= bigIntFilter.lte)) return false;`); + }); +} + +function addGtHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof bigIntFilter.gt === "number" || typeof bigIntFilter.gt === "bigint")`).block(() => { + writer.writeLine(`if (value === null) return false;`).writeLine(`if (!(value > bigIntFilter.gt)) return false;`); + }); +} + +function addGteHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof bigIntFilter.gte === "number" || typeof bigIntFilter.gte === "bigint")`).block(() => { + writer.writeLine(`if (value === null) return false;`).writeLine(`if (!(value >= bigIntFilter.gte)) return false;`); + }); +} diff --git a/packages/generator/src/fileCreators/utils/filters/BoolFilter.ts b/packages/generator/src/fileCreators/utils/filters/BoolFilter.ts new file mode 100644 index 0000000..11751da --- /dev/null +++ b/packages/generator/src/fileCreators/utils/filters/BoolFilter.ts @@ -0,0 +1,60 @@ +import type { CodeBlockWriter, SourceFile } from "ts-morph"; + +export function addBoolFilter(utilsFile: SourceFile) { + utilsFile.addFunction({ + name: "whereBoolFilter", + isExported: true, + typeParameters: [{ name: "T" }, { name: "R", constraint: `Prisma.Result` }], + parameters: [ + { name: "record", type: `R` }, + { name: "fieldName", type: "keyof R" }, + { + name: "boolFilter", + type: "Prisma.BoolFilter | boolean | undefined | null", + }, + ], + returnType: "boolean", + statements: (writer) => { + writer + .writeLine(`if (boolFilter === undefined) return true;`) + .blankLine() + .writeLine(`const value = record[fieldName] as boolean | null;`) + .writeLine(`if (boolFilter === null) return value === null;`) + .blankLine() + .writeLine(`if (typeof boolFilter === 'boolean')`) + .block(() => { + writer.writeLine(`if (value !== boolFilter) return false;`); + }) + .writeLine(`else`) + .block(() => { + addEqualsHandler(writer); + addNotHandler(writer); + }) + .writeLine(`return true;`); + }, + }); +} + +function addEqualsHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (boolFilter.equals === null)`) + .block(() => { + writer.writeLine(`if (value !== null) return false;`); + }) + .writeLine(`if (typeof boolFilter.equals === "boolean")`) + .block(() => { + writer.writeLine(`if (boolFilter.equals != value) return false;`); + }); +} + +function addNotHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (boolFilter.not === null)`) + .block(() => { + writer.writeLine(`if (value === null) return false;`); + }) + .writeLine(`if (typeof boolFilter.not === "boolean")`) + .block(() => { + writer.writeLine(`if (boolFilter.not == value) return false;`); + }); +} diff --git a/packages/generator/src/fileCreators/utils/filters/BytesFilter.ts b/packages/generator/src/fileCreators/utils/filters/BytesFilter.ts new file mode 100644 index 0000000..e9f8fcb --- /dev/null +++ b/packages/generator/src/fileCreators/utils/filters/BytesFilter.ts @@ -0,0 +1,84 @@ +import type { CodeBlockWriter, SourceFile } from "ts-morph"; + +export function addBytesFilter(utilsFile: SourceFile) { + utilsFile.addFunction({ + name: "whereBytesFilter", + isExported: true, + typeParameters: [{ name: "T" }, { name: "R", constraint: `Prisma.Result` }], + parameters: [ + { name: "record", type: `R` }, + { name: "fieldName", type: "keyof R" }, + { + name: "bytesFilter", + type: "Prisma.BytesFilter | Buffer | undefined | null", + }, + ], + returnType: "boolean", + statements: (writer) => { + writer + .writeLine(`if (bytesFilter === undefined) return true;`) + .blankLine() + .writeLine(`const value = record[fieldName] as Buffer | null;`) + .writeLine(`if (bytesFilter === null) return value === null;`) + .blankLine() + .writeLine(`if (Buffer.isBuffer(bytesFilter))`) + .block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (!bytesFilter.equals(value)) return false;`); + }) + .writeLine(`else`) + .block(() => { + addEqualsHandler(writer); + addNotHandler(writer); + addInHandler(writer); + addNotInHandler(writer); + }) + .writeLine(`return true;`); + }, + }); +} + +function addEqualsHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (bytesFilter.equals === null)`) + .block(() => { + writer.writeLine(`if (value !== null) return false;`); + }) + .writeLine(`if (Buffer.isBuffer(bytesFilter.equals))`) + .block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (!bytesFilter.equals.equals(value)) return false;`); + }); +} + +function addNotHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (bytesFilter.not === null)`) + .block(() => { + writer.writeLine(`if (value === null) return false;`); + }) + .writeLine(`if (Buffer.isBuffer(bytesFilter.not))`) + .block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (bytesFilter.not.equals(value)) return false;`); + }); +} + +function addInHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (Array.isArray(bytesFilter.in))`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (!bytesFilter.in.some((buffer) => buffer.equals(value))) return false;`); + }); +} + +function addNotInHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (Array.isArray(bytesFilter.notIn))`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (bytesFilter.notIn.some((buffer) => buffer.equals(value))) return false;`); + }); +} diff --git a/packages/generator/src/fileCreators/utils/filters/DateTimeFilter.ts b/packages/generator/src/fileCreators/utils/filters/DateTimeFilter.ts new file mode 100644 index 0000000..54cc078 --- /dev/null +++ b/packages/generator/src/fileCreators/utils/filters/DateTimeFilter.ts @@ -0,0 +1,124 @@ +import type { CodeBlockWriter, SourceFile } from "ts-morph"; + +export function addDateTimeFilter(utilsFile: SourceFile) { + utilsFile.addFunction({ + name: "whereDateTimeFilter", + isExported: true, + typeParameters: [{ name: "T" }, { name: "R", constraint: `Prisma.Result` }], + parameters: [ + { name: "record", type: `R` }, + { name: "fieldName", type: "keyof R" }, + { + name: "dateTimeFilter", + type: "string | Prisma.DateTimeFilter | Date | undefined", + }, + ], + returnType: "boolean", + statements: (writer) => { + writer + .writeLine(`if (dateTimeFilter === undefined) return true;`) + .blankLine() + .writeLine(`const value = record[fieldName] as Date | null;`) + .writeLine(`if (dateTimeFilter === null) return value === null;`) + .blankLine() + .writeLine(`if (typeof dateTimeFilter === "string" || dateTimeFilter instanceof Date)`) + .block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (new Date(dateTimeFilter).getTime() !== value.getTime()) return false;`); + }) + .writeLine(`else`) + .block(() => { + addEqualsHandler(writer); + addNotHandler(writer); + addInHandler(writer); + addNotInHandler(writer); + addLtHandler(writer); + addLteHandler(writer); + addGtHandler(writer); + addGteHandler(writer); + }) + .writeLine(`return true;`); + }, + }); +} + +function addEqualsHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (dateTimeFilter.equals === null)`) + .block(() => { + writer.writeLine(`if (value !== null) return false;`); + }) + .writeLine(`if (typeof dateTimeFilter.equals === "string" || dateTimeFilter.equals instanceof Date)`) + .block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (new Date(dateTimeFilter.equals).getTime() !== value.getTime()) return false;`); + }); +} + +function addNotHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (dateTimeFilter.not === null)`) + .block(() => { + writer.writeLine(`if (value === null) return false;`); + }) + .writeLine(`if (typeof dateTimeFilter.equals === "string" || dateTimeFilter.equals instanceof Date)`) + .block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (new Date(dateTimeFilter.equals).getTime() === value.getTime()) return false;`); + }); +} + +function addInHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (Array.isArray(dateTimeFilter.in))`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine( + `if (!dateTimeFilter.in.map((d) => new Date(d)).some((d) => d.getTime() === value.getTime())) return false;`, + ); + }); +} + +function addNotInHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (Array.isArray(dateTimeFilter.notIn))`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine( + `if (dateTimeFilter.notIn.map((d) => new Date(d)).some((d) => d.getTime() === value.getTime())) return false;`, + ); + }); +} + +function addLtHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof dateTimeFilter.lt === "string" || dateTimeFilter.lt instanceof Date)`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (!(value.getTime() < new Date(dateTimeFilter.lt).getTime())) return false;`); + }); +} + +function addLteHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof dateTimeFilter.lte === "string" || dateTimeFilter.lte instanceof Date)`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (!(value.getTime() <= new Date(dateTimeFilter.lte).getTime())) return false;`); + }); +} + +function addGtHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof dateTimeFilter.gt === "string" || dateTimeFilter.gt instanceof Date)`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (!(value.getTime() > new Date(dateTimeFilter.gt).getTime())) return false;`); + }); +} + +function addGteHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof dateTimeFilter.gte === "string" || dateTimeFilter.gte instanceof Date)`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (!(value.getTime() >= new Date(dateTimeFilter.gte).getTime())) return false;`); + }); +} diff --git a/packages/generator/src/fileCreators/utils/filters/NumberFilter.ts b/packages/generator/src/fileCreators/utils/filters/NumberFilter.ts new file mode 100644 index 0000000..7947442 --- /dev/null +++ b/packages/generator/src/fileCreators/utils/filters/NumberFilter.ts @@ -0,0 +1,106 @@ +import type { CodeBlockWriter, SourceFile } from "ts-morph"; + +export function addNumberFilter(utilsFile: SourceFile) { + utilsFile.addFunction({ + name: "whereNumberFilter", + isExported: true, + typeParameters: [{ name: "T" }, { name: "R", constraint: `Prisma.Result` }], + parameters: [ + { name: "record", type: `R` }, + { name: "fieldName", type: "keyof R" }, + { + name: "numberFilter", + type: "Prisma.IntFilter | Prisma.FloatFilter | number | undefined | null", + }, + ], + returnType: "boolean", + statements: (writer) => { + writer + .writeLine(`if (numberFilter === undefined) return true;`) + .blankLine() + .writeLine(`const value = record[fieldName] as number | null;`) + .writeLine(`if (numberFilter === null) return value === null;`) + .blankLine() + .writeLine(`if (typeof numberFilter === 'number')`) + .block(() => { + writer.writeLine(`if (value !== numberFilter) return false;`); + }) + .writeLine(`else`) + .block(() => { + addEqualsHandler(writer); + addNotHandler(writer); + addInHandler(writer); + addNotInHandler(writer); + addLtHandler(writer); + addLteHandler(writer); + addGtHandler(writer); + addGteHandler(writer); + }) + .writeLine(`return true;`); + }, + }); +} + +function addEqualsHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (numberFilter.equals === null)`) + .block(() => { + writer.writeLine(`if (value !== null) return false;`); + }) + .writeLine(`if (typeof numberFilter.equals === "number")`) + .block(() => { + writer.writeLine(`if (numberFilter.equals !== value) return false;`); + }); +} + +function addNotHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (numberFilter.not === null)`) + .block(() => { + writer.writeLine(`if (value === null) return false;`); + }) + .writeLine(`if (typeof numberFilter.not === "number")`) + .block(() => { + writer.writeLine(`if (numberFilter.not === value) return false;`); + }); +} + +function addInHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (Array.isArray(numberFilter.in))`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (!numberFilter.in.includes(value)) return false;`); + }); +} + +function addNotInHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (Array.isArray(numberFilter.notIn))`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (numberFilter.notIn.includes(value)) return false;`); + }); +} + +function addLtHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof numberFilter.lt === "number")`).block(() => { + writer.writeLine(`if (value === null) return false;`).writeLine(`if (!(value < numberFilter.lt)) return false;`); + }); +} + +function addLteHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof numberFilter.lte === "number")`).block(() => { + writer.writeLine(`if (value === null) return false;`).writeLine(`if (!(value <= numberFilter.lte)) return false;`); + }); +} + +function addGtHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof numberFilter.gt === "number")`).block(() => { + writer.writeLine(`if (value === null) return false;`).writeLine(`if (!(value > numberFilter.gt)) return false;`); + }); +} + +function addGteHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof numberFilter.gte === "number")`).block(() => { + writer.writeLine(`if (value === null) return false;`).writeLine(`if (!(value >= numberFilter.gte)) return false;`); + }); +} diff --git a/packages/generator/src/fileCreators/utils/filters/StringFilter.ts b/packages/generator/src/fileCreators/utils/filters/StringFilter.ts new file mode 100644 index 0000000..1f8cbee --- /dev/null +++ b/packages/generator/src/fileCreators/utils/filters/StringFilter.ts @@ -0,0 +1,194 @@ +import type { CodeBlockWriter, SourceFile } from "ts-morph"; + +export function addStringFilter(utilsFile: SourceFile) { + utilsFile.addFunction({ + name: "whereStringFilter", + isExported: true, + typeParameters: [{ name: "T" }, { name: "R", constraint: `Prisma.Result` }], + parameters: [ + { name: "record", type: `R` }, + { name: "fieldName", type: "keyof R" }, + { + name: "stringFilter", + type: "Prisma.StringFilter | Prisma.StringNullableFilter | string | undefined | null", + }, + ], + returnType: "boolean", + statements: (writer) => { + writer + .writeLine(`if (stringFilter === undefined) return true;`) + .blankLine() + .writeLine(`const value = record[fieldName] as string | null;`) + .writeLine(`if (stringFilter === null) return value === null;`) + .blankLine() + .writeLine(`if (typeof stringFilter === 'string')`) + .block(() => { + writer.writeLine(`if (value !== stringFilter) return false;`); + }) + .writeLine(`else`) + .block(() => { + addEqualsHandler(writer); + addNotHandler(writer); + addInHandler(writer); + addNotInHandler(writer); + addLtHandler(writer); + addLteHandler(writer); + addGtHandler(writer); + addGteHandler(writer); + addContainsHandler(writer); + addStartsWithHandler(writer); + addEndsWithHandler(writer); + }) + .writeLine(`return true;`); + }, + }); +} + +function addEqualsHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (stringFilter.equals === null)`) + .block(() => { + writer.writeLine(`if (value !== null) return false;`); + }) + .writeLine(`if (typeof stringFilter.equals === "string")`) + .block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (stringFilter.mode === 'insensitive')`) + .block(() => { + writer.writeLine(`if (stringFilter.equals.toLowerCase() !== value.toLowerCase()) return false;`); + }) + .writeLine(`else`) + .block(() => { + writer.writeLine(`if (stringFilter.equals !== value) return false;`); + }); + }); +} + +function addNotHandler(writer: CodeBlockWriter) { + writer + .writeLine(`if (stringFilter.not === null)`) + .block(() => { + writer.writeLine(`if (value === null) return false;`); + }) + .writeLine(`if (typeof stringFilter.not === "string")`) + .block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (stringFilter.mode === 'insensitive')`) + .block(() => { + writer.writeLine(`if (stringFilter.not.toLowerCase() === value.toLowerCase()) return false;`); + }) + .writeLine(`else`) + .block(() => { + writer.writeLine(`if (stringFilter.not === value) return false;`); + }); + }); +} + +function addInHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (Array.isArray(stringFilter.in))`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (stringFilter.mode === 'insensitive')`) + .block(() => { + writer.writeLine( + `if (!stringFilter.in.map((s) => s.toLowerCase()).includes(value.toLowerCase())) return false;`, + ); + }) + .writeLine(`else`) + .block(() => { + writer.writeLine(`if (!stringFilter.in.includes(value)) return false;`); + }); + }); +} + +function addNotInHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (Array.isArray(stringFilter.notIn))`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (stringFilter.mode === 'insensitive')`) + .block(() => { + writer.writeLine( + `if (stringFilter.notIn.map((s) => s.toLowerCase()).includes(value.toLowerCase())) return false;`, + ); + }) + .writeLine(`else`) + .block(() => { + writer.writeLine(`if (stringFilter.notIn.includes(value)) return false;`); + }); + }); +} + +function addLtHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof stringFilter.lt === "string")`).block(() => { + writer.writeLine(`if (value === null) return false;`); + writer.writeLine(`if (!(value < stringFilter.lt)) return false;`); + }); +} + +function addLteHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof stringFilter.lte === "string")`).block(() => { + writer.writeLine(`if (value === null) return false;`); + writer.writeLine(`if (!(value <= stringFilter.lte)) return false;`); + }); +} + +function addGtHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof stringFilter.gt === "string")`).block(() => { + writer.writeLine(`if (value === null) return false;`); + writer.writeLine(`if (!(value > stringFilter.gt)) return false;`); + }); +} + +function addGteHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof stringFilter.gte === "string")`).block(() => { + writer.writeLine(`if (value === null) return false;`); + writer.writeLine(`if (!(value >= stringFilter.gte)) return false;`); + }); +} + +function addContainsHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof stringFilter.contains === "string")`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (stringFilter.mode === 'insensitive')`) + .block(() => { + writer.writeLine(`if (!value.toLowerCase().includes(stringFilter.contains.toLowerCase())) return false;`); + }) + .writeLine(`else`) + .block(() => { + writer.writeLine(`if (!value.includes(stringFilter.contains)) return false;`); + }); + }); +} + +function addStartsWithHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof stringFilter.startsWith === "string")`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (stringFilter.mode === 'insensitive')`) + .block(() => { + writer.writeLine(`if (!value.toLowerCase().startsWith(stringFilter.startsWith.toLowerCase())) return false;`); + }) + .writeLine(`else`) + .block(() => { + writer.writeLine(`if (!value.startsWith(stringFilter.startsWith)) return false;`); + }); + }); +} + +function addEndsWithHandler(writer: CodeBlockWriter) { + writer.writeLine(`if (typeof stringFilter.endsWith === "string")`).block(() => { + writer + .writeLine(`if (value === null) return false;`) + .writeLine(`if (stringFilter.mode === 'insensitive')`) + .block(() => { + writer.writeLine(`if (!value.toLowerCase().endsWith(stringFilter.endsWith.toLowerCase())) return false;`); + }) + .writeLine(`else`) + .block(() => { + writer.writeLine(`if (!value.endsWith(stringFilter.endsWith)) return false;`); + }); + }); +} diff --git a/packages/generator/src/fillDefaultsFunction.ts b/packages/generator/src/fillDefaultsFunction.ts deleted file mode 100644 index 7f01c4d..0000000 --- a/packages/generator/src/fillDefaultsFunction.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { ClassDeclaration, CodeBlockWriter, Scope } from "ts-morph"; - -function addUuidDefault(writer: CodeBlockWriter) { - writer.write("data[fieldName] = crypto.randomUUID() as (typeof data)[typeof fieldName];"); -} - -function addCuidDefault(writer: CodeBlockWriter) { - writer - .write("const { createId } = await import('@paralleldrive/cuid2');") - .write("data[fieldName] = createId() as (typeof data)[typeof fieldName];"); -} - -function addAutoincrementDefault(writer: CodeBlockWriter) { - writer - .write("const transaction = this.client.db.transaction(this.model.name, 'readonly');") - .write("const store = transaction.objectStore(this.model.name);") - .write("const cursor = await store.openCursor(null, 'prev');") - .write("data[fieldName] = (cursor ? Number(cursor.key) + 1 : 1) as (typeof data)[typeof fieldName];"); -} - -function addDefaultValue(writer: CodeBlockWriter) { - writer.write("data[fieldName] = defaultValue as (typeof data)[typeof fieldName];"); -} - -function convertStringDatesToDates(writer: CodeBlockWriter) { - writer - .writeLine('this.model.fields.filter((field) => field.type === "DateTime")') - .writeLine(".forEach((field) => ") - .block(() => { - writer - .writeLine("const fieldName = field.name as keyof D;") - .writeLine('if (typeof data[fieldName] === "string")') - .block(() => { - writer.writeLine("data[fieldName] = new Date(data[fieldName]) as D[keyof D];"); - }); - }) - .writeLine(")"); -} - -export function addFillDefaultsFunction(modelClass: ClassDeclaration) { - modelClass.addMethod({ - name: "fillDefaults", - isAsync: true, - scope: Scope.Private, - typeParameters: [ - { name: "Q", constraint: 'Prisma.Args' }, - { name: "D", default: 'Prisma.Args["data"]' }, - ], - parameters: [{ name: "data", type: "D" }], - returnType: "Promise>", - statements: (writer) => { - writer - .writeLine("if (data === undefined) data = {} as D;") - .writeLine("await Promise.all(") - .indent(() => { - writer.write("this.model.fields").indent(() => - writer - .write(".filter(({ hasDefaultValue }) => hasDefaultValue)") - .write(".map(async (field) => {") - .indent(() => - writer - .write("const fieldName = field.name as keyof D;") - .write("const defaultValue = field.default!;") - .write("if (data[fieldName] === undefined) {") - .indent(() => - writer - .write("if (typeof defaultValue === 'object' && 'name' in defaultValue) {") - .indent(() => - writer - .write("if (defaultValue.name === 'uuid(4)') {") - .indent(() => addUuidDefault(writer)) - .write("} else if (defaultValue.name === 'cuid') {") - .indent(() => addCuidDefault(writer)) - .write("} else if (defaultValue.name === 'autoincrement') {") - .indent(() => addAutoincrementDefault(writer)) - .write("}"), - ) - .write("} else {") - .indent(() => addDefaultValue(writer)) - .write("}"), - ) - .write("}"), - ) - .write("})"), - ); - }) - .writeLine(")"); - convertStringDatesToDates(writer); - writer.writeLine("return data as unknown as Prisma.Result;"); - }, - }); -} diff --git a/packages/generator/src/generator.ts b/packages/generator/src/generator.ts index db478a3..42f9087 100644 --- a/packages/generator/src/generator.ts +++ b/packages/generator/src/generator.ts @@ -1,23 +1,10 @@ import { generatorHandler, GeneratorOptions } from "@prisma/generator-helper"; -import path from "path"; -import { Project, SourceFile, VariableDeclarationKind } from "ts-morph"; +import { Project } from "ts-morph"; import { version } from "../package.json"; -import { addBaseModelClass, addClientClass, addImports, addTypes } from "./fileBaseFunctions"; -import { createInterfaceFile } from "./interfaceSchema"; -import { outputUtilsText } from "./outputUtils"; -import { writeFileSafely } from "./utils"; - -async function createAndWriteSourceFile( - project: Project, - filename: string, - outputPath: string, - callback: (file: SourceFile) => void, -) { - const file = project.createSourceFile(filename, "", { overwrite: true }); - callback(file); - const writeLocation = path.join(outputPath, file.getBaseName()); - await writeFileSafely(writeLocation, file.getText()); -} +import { createIDBInterfaceFile } from "./fileCreators/idb-interface/create"; +import { createPrismaIDBClientFile } from "./fileCreators/prisma-idb-client/create"; +import { writeSourceFile } from "./helpers/fileWriting"; +import { createUtilsFile } from "./fileCreators/utils/create"; generatorHandler({ onManifest() { @@ -30,44 +17,18 @@ generatorHandler({ onGenerate: async (options: GeneratorOptions) => { const project = new Project(); const { models } = options.dmmf.datamodel; - const outputPath = options.generator.output?.value as string; - createAndWriteSourceFile(project, "prisma-idb-client.ts", outputPath, (file) => { - // TODO: update version numbers if schema changes - file.addVariableStatement({ - declarationKind: VariableDeclarationKind.Const, - declarations: [{ name: "IDB_VERSION", type: "number", initializer: "1" }], - }); - - addImports(file); - addTypes(file, models); - addClientClass(file, models); - addBaseModelClass(file); - file.organizeImports(); + await writeSourceFile(project, "prisma-idb-client.ts", outputPath, (file) => { + createPrismaIDBClientFile(file, models); }); - createAndWriteSourceFile(project, "idb-interface.ts", outputPath, (file) => { - createInterfaceFile(file, models); + await writeSourceFile(project, "idb-interface.ts", outputPath, (file) => { + createIDBInterfaceFile(file, models); }); - createAndWriteSourceFile(project, "datamodel.ts", outputPath, (file) => { - file.addImportDeclaration({ - isTypeOnly: true, - moduleSpecifier: "@prisma/client/runtime/library", - namedImports: ["DMMF"], - }); - file.addVariableStatement({ - declarationKind: VariableDeclarationKind.Const, - isExported: true, - declarations: models.map((model) => ({ - name: model.name, - type: "DMMF.Datamodel['models'][number]", - initializer: JSON.stringify(model), - })), - }); + await writeSourceFile(project, "idb-utils.ts", outputPath, (file) => { + createUtilsFile(file); }); - - await writeFileSafely(path.join(options.generator.output?.value as string, "utils.ts"), outputUtilsText); }, }); diff --git a/packages/generator/src/helpers/fileWriting.ts b/packages/generator/src/helpers/fileWriting.ts new file mode 100644 index 0000000..1fd3ef0 --- /dev/null +++ b/packages/generator/src/helpers/fileWriting.ts @@ -0,0 +1,44 @@ +import fs from "fs"; +import path from "path"; +import prettier from "prettier"; +import { Project, SourceFile } from "ts-morph"; + +export async function writeSourceFile( + project: Project, + filename: string, + outputPath: string, + callback: (file: SourceFile) => void, +) { + const file = project.createSourceFile(filename, "", { overwrite: true }); + callback(file); + file.organizeImports(); + const writeLocation = path.join(outputPath, file.getBaseName()); + await writeFileSafely(writeLocation, file.getText()); +} + +const formatFile = (content: string, filepath: string): Promise => { + return new Promise((res, rej) => + prettier.resolveConfig(filepath).then((options) => { + if (!options) res(content); + + try { + const formatted = prettier.format(content, { + ...options, + parser: "typescript", + }); + + res(formatted); + } catch (error) { + rej(error); + } + }), + ); +}; + +const writeFileSafely = async (writeLocation: string, content: string) => { + fs.mkdirSync(path.dirname(writeLocation), { + recursive: true, + }); + + fs.writeFileSync(writeLocation, await formatFile(content, writeLocation)); +}; diff --git a/packages/generator/src/helpers/utils.ts b/packages/generator/src/helpers/utils.ts new file mode 100644 index 0000000..c67fa7d --- /dev/null +++ b/packages/generator/src/helpers/utils.ts @@ -0,0 +1,72 @@ +import { Model } from "../fileCreators/types"; + +export function toCamelCase(str: string): string { + return str + .replace(/[_\s-]+(.)?/g, (_, chr) => (chr ? chr.toUpperCase() : "")) + .replace(/^(.)/, (match) => match.toLowerCase()); +} + +function createIdentifierTuple(fieldNames: readonly string[], model: Model) { + return JSON.stringify( + fieldNames.map((keyFieldName) => { + const keyField = model.fields.find(({ name }) => keyFieldName === name)!; + return `${keyField.name}: Prisma.${model.name}['${keyField.name}']`; + }), + ).replaceAll('"', ""); +} + +export function getUniqueIdentifiers(model: Model) { + const uniqueIdentifiers: { name: string; keyPath: string; keyPathType: string }[] = []; + + if (model.primaryKey) { + const name = model.primaryKey.name ?? model.primaryKey.fields.join("_"); + uniqueIdentifiers.push({ + name, + keyPath: JSON.stringify(model.primaryKey.fields), + keyPathType: createIdentifierTuple(model.primaryKey.fields, model), + }); + } + + const idField = model.fields.find(({ isId }) => isId); + if (idField) { + uniqueIdentifiers.push({ + name: idField.name, + keyPath: JSON.stringify([idField.name]), + keyPathType: createIdentifierTuple([idField.name], model), + }); + } + + const uniqueField = model.fields.filter(({ isUnique }) => isUnique); + uniqueField.forEach((uniqueField) => { + uniqueIdentifiers.push({ + name: uniqueField.name, + keyPath: JSON.stringify([uniqueField.name]), + keyPathType: createIdentifierTuple([uniqueField.name], model), + }); + }); + + const compositeUniqueFields = model.uniqueIndexes; + compositeUniqueFields.forEach(({ name, fields }) => { + name = name ?? fields.join("_"); + uniqueIdentifiers.push({ + name, + keyPath: JSON.stringify(fields), + keyPathType: createIdentifierTuple(fields, model), + }); + }); + + if (uniqueIdentifiers.length === 0) throw new Error(`Unable to generate valid IDB key for ${model.name}`); + return uniqueIdentifiers; +} + +export function getModelFieldData(model: Model) { + const keyPath = JSON.parse(getUniqueIdentifiers(model)[0].keyPath); + const nonKeyUniqueFields = model.fields.filter(({ isUnique, name }) => isUnique && !keyPath.includes(name)); + const storeName = toCamelCase(model.name); + + const optionalFields = model.fields.filter((field) => !field.isRequired); + const fieldsWithDefaultValue = model.fields.filter((field) => field.hasDefaultValue); + const allRequiredFieldsHaveDefaults = fieldsWithDefaultValue.length === model.fields.length - optionalFields.length; + + return { optionalFields, fieldsWithDefaultValue, allRequiredFieldsHaveDefaults, nonKeyUniqueFields, storeName }; +} diff --git a/packages/generator/src/interfaceSchema.ts b/packages/generator/src/interfaceSchema.ts deleted file mode 100644 index f4126f9..0000000 --- a/packages/generator/src/interfaceSchema.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { DMMF } from "@prisma/generator-helper"; -import { SourceFile } from "ts-morph"; -import { prismaToJsTypes } from "./types"; -import { generateIDBKey } from "./utils"; - -export function createInterfaceFile(idbInterfaceFile: SourceFile, models: DMMF.Datamodel["models"]) { - idbInterfaceFile.addImportDeclaration({ isTypeOnly: true, namedImports: ["DBSchema"], moduleSpecifier: "idb" }); - idbInterfaceFile.addImportDeclaration({ namespaceImport: "Prisma", moduleSpecifier: "@prisma/client" }); - - idbInterfaceFile.addInterface({ - name: "PrismaIDBSchema", - extends: ["DBSchema"], - isExported: true, - properties: models.map((model) => ({ - name: model.name, - type: (writer) => { - const keyPath = JSON.parse(generateIDBKey(model)) as string[]; - const idbKeyPath = JSON.stringify( - keyPath.map((keyFieldName) => { - const keyField = model.fields.find(({ name }) => keyFieldName === name)!; - const keyFieldJsType = prismaToJsTypes.get(keyField.type); - if (!keyFieldJsType) throw new Error(`Key field type: ${keyField.type} is not supported`); - return keyFieldJsType; - }), - ).replaceAll('"', ""); - - writer.block(() => { - writer - .writeLine(`key: ${idbKeyPath}`) - .writeLine(`value: Prisma.${model.name}`) - .writeLine("indexes: { [s: string]: IDBValidKey }"); - }); - }, - })), - }); -} diff --git a/packages/generator/src/outputUtils.ts b/packages/generator/src/outputUtils.ts deleted file mode 100644 index 869376b..0000000 --- a/packages/generator/src/outputUtils.ts +++ /dev/null @@ -1,141 +0,0 @@ -export const outputUtilsText = ` -import type { Prisma } from "@prisma/client"; -import type { DMMF } from "@prisma/client/runtime/library"; -import type { ModelDelegate } from "./prisma-idb-client"; - -export type Model = DMMF.Datamodel["models"][number]; - -export function intersectArraysByNestedKey>( - arrays: Prisma.Result[][], - keyPath: string[], -): Prisma.Result[] { - return arrays.reduce((acc, array) => - acc.filter((item) => - array.some((el) => - keyPath.every( - (key) => - el[key as keyof Prisma.Result] === - item[key as keyof Prisma.Result], - ), - ), - ), - ); -} - -export function getModelFieldData(model: Model) { - const keyPath = JSON.parse(generateIDBKey(model)); - const nonKeyUniqueFields = model.fields.filter(({ isUnique, name }) => isUnique && !keyPath.includes(name)); - const storeName = toCamelCase(model.name); - - const optionalFields = model.fields.filter((field) => !field.isRequired); - const fieldsWithDefaultValue = model.fields.filter((field) => field.hasDefaultValue); - const allRequiredFieldsHaveDefaults = fieldsWithDefaultValue.length === model.fields.length - optionalFields.length; - - return { optionalFields, fieldsWithDefaultValue, allRequiredFieldsHaveDefaults, nonKeyUniqueFields, storeName }; -} - -export function toCamelCase(str: string): string { - return str - .replace(/[_\s-]+(.)?/g, (_, chr) => (chr ? chr.toUpperCase() : "")) - .replace(/^(.)/, (match) => match.toLowerCase()); -} - -export function generateIDBKey(model: Model) { - if (model.primaryKey) return JSON.stringify(model.primaryKey.fields); - - const idField = model.fields.find(({ isId }) => isId); - if (idField) return JSON.stringify([idField.name]); - - const uniqueField = model.fields.find(({ isUnique }) => isUnique)!; - return JSON.stringify([uniqueField.name]); -} - -export function removeDuplicatesByKeyPath>( - array: Prisma.Result[][], - keyPath: string[], -): Prisma.Result[] { - const seen = new Set(); - return array - .flatMap((el) => el) - .filter((item) => { - const key = JSON.stringify(keyPath.map((key) => item[key as keyof Prisma.Result])); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -export function isLogicalOperator(param: unknown) { - return param === "AND" || param === "OR" || param === "NOT"; -} - -export function filterByWhereClause>( - records: Prisma.Result[], - keyPath: string[], - whereClause: Prisma.Args["where"], -): Prisma.Result[] { - if (whereClause === undefined) return records; - - for (const [unsafeParam, value] of Object.entries(whereClause)) { - const param = unsafeParam as keyof Prisma.Result; - if (value === undefined) continue; - - if (isLogicalOperator(param)) { - const operands = Array.isArray(whereClause[param]) ? whereClause[param] : [whereClause[param]]; - - if (param === "AND") { - records = intersectArraysByNestedKey( - operands.map((operandClause) => filterByWhereClause(records, keyPath, operandClause)), - keyPath, - ); - } - - if (param === "OR") { - records = removeDuplicatesByKeyPath( - operands.map((operandClause) => filterByWhereClause(records, keyPath, operandClause)), - keyPath, - ); - } - - if (param === "NOT") { - const excludedRecords = removeDuplicatesByKeyPath( - operands.map((operandClause) => filterByWhereClause(records, keyPath, operandClause)), - keyPath, - ); - records = records.filter( - (item) => - !excludedRecords.some((excluded) => - keyPath.every( - (key) => - excluded[key as keyof Prisma.Result] === - item[key as keyof Prisma.Result], - ), - ), - ); - } - } - - records = records.filter((record) => { - // TODO: really hacky way to avoid checking for complex filters like gte and lte for now - const typedWhereClause = whereClause as Record; - const typedParam = param as string; - return record[param] === typedWhereClause[typedParam]; - }); - } - - return records; -} - -export const prismaToJsTypes = new Map([ - ["String", "string"], - ["Boolean", "boolean"], - ["Int", "number"], - ["BigInt", "bigint"], - ["Float", "number"], - ["Decimal", "string"], - ["DateTime", "Date"], - ["Json", "object"], - ["Bytes", "Buffer"], - ["Unsupported", "unknown"], -] as const); -`; diff --git a/packages/generator/src/types.ts b/packages/generator/src/types.ts deleted file mode 100644 index 1086f21..0000000 --- a/packages/generator/src/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { DMMF, ReadonlyDeep } from "@prisma/generator-helper"; - -export type Model = DMMF.Datamodel["models"][number]; - -export type Field = Model["fields"][number]; - -export type FunctionalDefaultValue = ReadonlyDeep<{ - name: string; - args: unknown[]; -}>; - -export const prismaToJsTypes = new Map([ - ["String", "string"], - ["Boolean", "boolean"], - ["Int", "number"], - ["BigInt", "bigint"], - ["Float", "number"], - ["Decimal", "string"], - ["DateTime", "Date"], - ["Json", "object"], - ["Bytes", "Buffer"], - ["Unsupported", "unknown"], -]); diff --git a/packages/generator/src/utils.ts b/packages/generator/src/utils.ts deleted file mode 100644 index 5b9e418..0000000 --- a/packages/generator/src/utils.ts +++ /dev/null @@ -1,59 +0,0 @@ -import fs from "fs"; -import path from "path"; -import prettier from "prettier"; -import { Model } from "./types"; - -export function toCamelCase(str: string): string { - return str - .replace(/[_\s-]+(.)?/g, (_, chr) => (chr ? chr.toUpperCase() : "")) - .replace(/^(.)/, (match) => match.toLowerCase()); -} - -export const formatFile = (content: string, filepath: string): Promise => { - return new Promise((res, rej) => - prettier.resolveConfig(filepath).then((options) => { - if (!options) res(content); - - try { - const formatted = prettier.format(content, { - ...options, - parser: "typescript", - }); - - res(formatted); - } catch (error) { - rej(error); - } - }), - ); -}; - -export function generateIDBKey(model: Model) { - if (model.primaryKey) return JSON.stringify(model.primaryKey.fields); - - const idField = model.fields.find(({ isId }) => isId); - if (idField) return JSON.stringify([idField.name]); - - const uniqueField = model.fields.find(({ isUnique }) => isUnique)!; - return JSON.stringify([uniqueField.name]); -} - -export function getModelFieldData(model: Model) { - const keyPath = JSON.parse(generateIDBKey(model)); - const nonKeyUniqueFields = model.fields.filter(({ isUnique, name }) => isUnique && !keyPath.includes(name)); - const storeName = toCamelCase(model.name); - - const optionalFields = model.fields.filter((field) => !field.isRequired); - const fieldsWithDefaultValue = model.fields.filter((field) => field.hasDefaultValue); - const allRequiredFieldsHaveDefaults = fieldsWithDefaultValue.length === model.fields.length - optionalFields.length; - - return { optionalFields, fieldsWithDefaultValue, allRequiredFieldsHaveDefaults, nonKeyUniqueFields, storeName }; -} - -export const writeFileSafely = async (writeLocation: string, content: string) => { - fs.mkdirSync(path.dirname(writeLocation), { - recursive: true, - }); - - fs.writeFileSync(writeLocation, await formatFile(content, writeLocation)); -}; diff --git a/packages/usage/package.json b/packages/usage/package.json index ae07bed..8710735 100644 --- a/packages/usage/package.json +++ b/packages/usage/package.json @@ -22,36 +22,41 @@ "@prisma-idb/prisma-idb-generator": "*", "@prisma/client": "^5.21.1", "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tailwindcss/typography": "^0.5.15", "@types/eslint": "^9.6.0", "autoprefixer": "^10.4.20", - "bits-ui": "^1.0.0-next.33", + "bits-ui": "^1.0.0-next.57", "clsx": "^2.1.1", "eslint": "^9.7.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.36.0", + "formsnap": "^2.0.0-next.1", "globals": "^15.0.0", "lucide-svelte": "^0.454.0", + "mode-watcher": "^0.4.1", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.6", "prettier-plugin-tailwindcss": "^0.6.5", "prisma": "^5.21.1", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "svelte-sonner": "^0.3.28", + "sveltekit-superforms": "^2.20.0", "tailwind-merge": "^2.5.4", "tailwind-variants": "^0.2.1", "tailwindcss": "^3.4.9", "tailwindcss-animate": "^1.0.7", "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", - "vite": "^5.0.3" + "vite": "^5.0.3", + "zod": "^3.23.8" }, "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "idb": "^8.0.0", - "mode-watcher": "^0.4.1", "uuid": "^11.0.2" } } diff --git a/packages/usage/playwright.config.ts b/packages/usage/playwright.config.ts index c9ab3e1..e295a38 100644 --- a/packages/usage/playwright.config.ts +++ b/packages/usage/playwright.config.ts @@ -2,16 +2,23 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./tests", - fullyParallel: true, + fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: 1, reporter: "html", use: { trace: "on-first-retry", video: "retain-on-failure" }, projects: [ - { name: "webkit", use: { ...devices["Desktop Safari"] } }, + // Can only use one or we get race conditions + /* + Example: user.create({ name: "John" }) in two projects but one database + The projects each will generate { id: 1 } as key, but the database will + have { id: 1 } and { id: 2 }, causing one project's test to fail + */ { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + + // { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + // { name: "webkit", use: { ...devices["Desktop Safari"] } }, /* Test against mobile viewports. */ // { // name: 'Mobile Chrome', @@ -34,8 +41,8 @@ export default defineConfig({ ], webServer: { - command: "npm run build && npm run preview", + command: "npx prisma db push --force-reset && npm run build && npm run preview", port: 4173, - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, }, }); diff --git a/packages/usage/src/lib/components/ui/form/form-button.svelte b/packages/usage/src/lib/components/ui/form/form-button.svelte new file mode 100644 index 0000000..03b0c91 --- /dev/null +++ b/packages/usage/src/lib/components/ui/form/form-button.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/usage/src/lib/components/ui/form/form-description.svelte b/packages/usage/src/lib/components/ui/form/form-description.svelte new file mode 100644 index 0000000..1e650fc --- /dev/null +++ b/packages/usage/src/lib/components/ui/form/form-description.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/usage/src/lib/components/ui/form/form-element-field.svelte b/packages/usage/src/lib/components/ui/form/form-element-field.svelte new file mode 100644 index 0000000..1098eca --- /dev/null +++ b/packages/usage/src/lib/components/ui/form/form-element-field.svelte @@ -0,0 +1,29 @@ + + + + + + {#snippet children({ constraints, errors, tainted, value })} +
+ {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} +
+ {/snippet} +
diff --git a/packages/usage/src/lib/components/ui/form/form-field-errors.svelte b/packages/usage/src/lib/components/ui/form/form-field-errors.svelte new file mode 100644 index 0000000..d836e9b --- /dev/null +++ b/packages/usage/src/lib/components/ui/form/form-field-errors.svelte @@ -0,0 +1,27 @@ + + + + {#snippet children({ errors, errorProps })} + {#if childrenProp} + {@render childrenProp({ errors, errorProps })} + {:else} + {#each errors as error} +
{error}
+ {/each} + {/if} + {/snippet} +
diff --git a/packages/usage/src/lib/components/ui/form/form-field.svelte b/packages/usage/src/lib/components/ui/form/form-field.svelte new file mode 100644 index 0000000..29f0062 --- /dev/null +++ b/packages/usage/src/lib/components/ui/form/form-field.svelte @@ -0,0 +1,29 @@ + + + + + + {#snippet children({ constraints, errors, tainted, value })} +
+ {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} +
+ {/snippet} +
diff --git a/packages/usage/src/lib/components/ui/form/form-fieldset.svelte b/packages/usage/src/lib/components/ui/form/form-fieldset.svelte new file mode 100644 index 0000000..a1825de --- /dev/null +++ b/packages/usage/src/lib/components/ui/form/form-fieldset.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/usage/src/lib/components/ui/form/form-label.svelte b/packages/usage/src/lib/components/ui/form/form-label.svelte new file mode 100644 index 0000000..f6bdf77 --- /dev/null +++ b/packages/usage/src/lib/components/ui/form/form-label.svelte @@ -0,0 +1,21 @@ + + + + {#snippet child({ props })} + + {/snippet} + diff --git a/packages/usage/src/lib/components/ui/form/form-legend.svelte b/packages/usage/src/lib/components/ui/form/form-legend.svelte new file mode 100644 index 0000000..76ee226 --- /dev/null +++ b/packages/usage/src/lib/components/ui/form/form-legend.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/usage/src/lib/components/ui/form/index.ts b/packages/usage/src/lib/components/ui/form/index.ts new file mode 100644 index 0000000..66a92ae --- /dev/null +++ b/packages/usage/src/lib/components/ui/form/index.ts @@ -0,0 +1,33 @@ +import * as FormPrimitive from "formsnap"; +import Description from "./form-description.svelte"; +import Label from "./form-label.svelte"; +import FieldErrors from "./form-field-errors.svelte"; +import Field from "./form-field.svelte"; +import Fieldset from "./form-fieldset.svelte"; +import Legend from "./form-legend.svelte"; +import ElementField from "./form-element-field.svelte"; +import Button from "./form-button.svelte"; + +const Control = FormPrimitive.Control; + +export { + Field, + Control, + Label, + Button, + FieldErrors, + Description, + Fieldset, + Legend, + ElementField, + // + Field as FormField, + Control as FormControl, + Description as FormDescription, + Label as FormLabel, + FieldErrors as FormFieldErrors, + Fieldset as FormFieldset, + Legend as FormLegend, + ElementField as FormElementField, + Button as FormButton, +}; diff --git a/packages/usage/src/lib/components/ui/select/index.ts b/packages/usage/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..d0afb65 --- /dev/null +++ b/packages/usage/src/lib/components/ui/select/index.ts @@ -0,0 +1,34 @@ +import { Select as SelectPrimitive } from "bits-ui"; + +import GroupHeading from "./select-group-heading.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; + +const Root = SelectPrimitive.Root; +const Group = SelectPrimitive.Group; + +export { + Root, + Group, + GroupHeading, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + // + Root as Select, + Group as SelectGroup, + GroupHeading as SelectGroupHeading, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, +}; diff --git a/packages/usage/src/lib/components/ui/select/select-content.svelte b/packages/usage/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..3538e98 --- /dev/null +++ b/packages/usage/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,34 @@ + + + + + + + {@render children?.()} + + + + diff --git a/packages/usage/src/lib/components/ui/select/select-group-heading.svelte b/packages/usage/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..5162ec3 --- /dev/null +++ b/packages/usage/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/usage/src/lib/components/ui/select/select-item.svelte b/packages/usage/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..f0228bc --- /dev/null +++ b/packages/usage/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,37 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/packages/usage/src/lib/components/ui/select/select-scroll-down-button.svelte b/packages/usage/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..bfc09ca --- /dev/null +++ b/packages/usage/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/packages/usage/src/lib/components/ui/select/select-scroll-up-button.svelte b/packages/usage/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..f61e791 --- /dev/null +++ b/packages/usage/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/packages/usage/src/lib/components/ui/select/select-separator.svelte b/packages/usage/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..517e6ba --- /dev/null +++ b/packages/usage/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,9 @@ + + + diff --git a/packages/usage/src/lib/components/ui/select/select-trigger.svelte b/packages/usage/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..9cc8bde --- /dev/null +++ b/packages/usage/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,24 @@ + + +span]:line-clamp-1", + className, + )} + {...restProps} +> + {@render children?.()} + + diff --git a/packages/usage/src/lib/components/ui/separator/index.ts b/packages/usage/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..d66644e --- /dev/null +++ b/packages/usage/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/packages/usage/src/lib/components/ui/separator/separator.svelte b/packages/usage/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..28c54ca --- /dev/null +++ b/packages/usage/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/usage/src/lib/components/ui/sonner/index.ts b/packages/usage/src/lib/components/ui/sonner/index.ts new file mode 100644 index 0000000..1ad9f4a --- /dev/null +++ b/packages/usage/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./sonner.svelte"; diff --git a/packages/usage/src/lib/components/ui/sonner/sonner.svelte b/packages/usage/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..ec63825 --- /dev/null +++ b/packages/usage/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,21 @@ + + + diff --git a/packages/usage/src/lib/components/ui/textarea/index.ts b/packages/usage/src/lib/components/ui/textarea/index.ts new file mode 100644 index 0000000..821ef31 --- /dev/null +++ b/packages/usage/src/lib/components/ui/textarea/index.ts @@ -0,0 +1,28 @@ +import Root from "./textarea.svelte"; + +type FormTextareaEvent = T & { + currentTarget: EventTarget & HTMLTextAreaElement; +}; + +type TextareaEvents = { + blur: FormTextareaEvent; + change: FormTextareaEvent; + click: FormTextareaEvent; + focus: FormTextareaEvent; + keydown: FormTextareaEvent; + keypress: FormTextareaEvent; + keyup: FormTextareaEvent; + mouseover: FormTextareaEvent; + mouseenter: FormTextareaEvent; + mouseleave: FormTextareaEvent; + paste: FormTextareaEvent; + input: FormTextareaEvent; +}; + +export { + Root, + // + Root as Textarea, + type TextareaEvents, + type FormTextareaEvent, +}; diff --git a/packages/usage/src/lib/components/ui/textarea/textarea.svelte b/packages/usage/src/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 0000000..c258de1 --- /dev/null +++ b/packages/usage/src/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,22 @@ + + + diff --git a/packages/usage/src/lib/prisma-idb/datamodel.ts b/packages/usage/src/lib/prisma-idb/datamodel.ts deleted file mode 100644 index 226d3cc..0000000 --- a/packages/usage/src/lib/prisma-idb/datamodel.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { DMMF } from "@prisma/client/runtime/library"; - -export const Todo: DMMF.Datamodel["models"][number] = { - name: "Todo", - dbName: null, - fields: [ - { - name: "id", - kind: "scalar", - isList: false, - isRequired: true, - isUnique: false, - isId: true, - isReadOnly: false, - hasDefaultValue: true, - type: "String", - default: { name: "uuid(4)", args: [] }, - isGenerated: false, - isUpdatedAt: false, - }, - { - name: "task", - kind: "scalar", - isList: false, - isRequired: true, - isUnique: false, - isId: false, - isReadOnly: false, - hasDefaultValue: false, - type: "String", - isGenerated: false, - isUpdatedAt: false, - }, - { - name: "isCompleted", - kind: "scalar", - isList: false, - isRequired: true, - isUnique: false, - isId: false, - isReadOnly: false, - hasDefaultValue: false, - type: "Boolean", - isGenerated: false, - isUpdatedAt: false, - }, - { - name: "timeToComplete", - kind: "scalar", - isList: false, - isRequired: true, - isUnique: false, - isId: false, - isReadOnly: false, - hasDefaultValue: false, - type: "Int", - isGenerated: false, - isUpdatedAt: false, - }, - ], - primaryKey: null, - uniqueFields: [], - uniqueIndexes: [], - isGenerated: false, -}; diff --git a/packages/usage/src/lib/prisma-idb/idb-interface.ts b/packages/usage/src/lib/prisma-idb/idb-interface.ts deleted file mode 100644 index bfe6458..0000000 --- a/packages/usage/src/lib/prisma-idb/idb-interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { DBSchema } from "idb"; -import * as Prisma from "@prisma/client"; - -export interface PrismaIDBSchema extends DBSchema { - Todo: { - key: [string]; - value: Prisma.Todo; - indexes: { [s: string]: IDBValidKey }; - }; -} diff --git a/packages/usage/src/lib/prisma-idb/prisma-idb-client.ts b/packages/usage/src/lib/prisma-idb/prisma-idb-client.ts deleted file mode 100644 index 51f134b..0000000 --- a/packages/usage/src/lib/prisma-idb/prisma-idb-client.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { IDBPDatabase } from "idb"; -import { openDB } from "idb"; -import * as models from "./datamodel"; -import type { PrismaIDBSchema } from "./idb-interface"; -import type { Model } from "./utils"; -import { filterByWhereClause, generateIDBKey, getModelFieldData, prismaToJsTypes } from "./utils"; - -const IDB_VERSION: number = 1; - -export type ModelDelegate = Prisma.TodoDelegate; -type ObjectStoreName = (typeof PrismaIDBClient.prototype.db.objectStoreNames)[number]; - -export class PrismaIDBClient { - private static instance: PrismaIDBClient; - db!: IDBPDatabase; - - private constructor() {} - - todo!: BaseIDBModelClass; - - public static async create(): Promise { - if (!PrismaIDBClient.instance) { - const client = new PrismaIDBClient(); - await client.initialize(); - PrismaIDBClient.instance = client; - } - return PrismaIDBClient.instance; - } - - private async initialize() { - this.db = await openDB("prisma-idb", IDB_VERSION, { - upgrade(db) { - db.createObjectStore("Todo", { keyPath: ["id"] }); - }, - }); - this.todo = new BaseIDBModelClass(this, ["id"], models.Todo); - } -} - -class BaseIDBModelClass { - private client: PrismaIDBClient; - private keyPath: string[]; - private model: Omit & { name: ObjectStoreName }; - private eventEmitter: EventTarget; - - constructor(client: PrismaIDBClient, keyPath: string[], model: Model) { - this.client = client; - this.keyPath = keyPath; - this.model = model as Omit & { name: ObjectStoreName }; - this.eventEmitter = new EventTarget(); - } - - subscribe(event: "create" | "update" | "delete" | ("create" | "update" | "delete")[], callback: () => void) { - if (Array.isArray(event)) { - event.forEach((event) => this.eventEmitter.addEventListener(event, callback)); - return; - } - this.eventEmitter.addEventListener(event, callback); - } - - unsubscribe(event: "create" | "update" | "delete" | ("create" | "update" | "delete")[], callback: () => void) { - if (Array.isArray(event)) { - event.forEach((event) => this.eventEmitter.removeEventListener(event, callback)); - return; - } - this.eventEmitter.removeEventListener(event, callback); - } - - private emit(event: "create" | "update" | "delete") { - this.eventEmitter.dispatchEvent(new Event(event)); - } - - private async fillDefaults, D = Prisma.Args["data"]>( - data: D, - ): Promise> { - if (data === undefined) data = {} as D; - await Promise.all( - this.model.fields - .filter(({ hasDefaultValue }) => hasDefaultValue) - .map(async (field) => { - const fieldName = field.name as keyof D; - const defaultValue = field.default!; - if (data[fieldName] === undefined) { - if (typeof defaultValue === "object" && "name" in defaultValue) { - if (defaultValue.name === "uuid(4)") { - data[fieldName] = crypto.randomUUID() as (typeof data)[typeof fieldName]; - } else if (defaultValue.name === "cuid") { - const { createId } = await import("@paralleldrive/cuid2"); - data[fieldName] = createId() as (typeof data)[typeof fieldName]; - } else if (defaultValue.name === "autoincrement") { - const transaction = this.client.db.transaction(this.model.name, "readonly"); - const store = transaction.objectStore(this.model.name); - const cursor = await store.openCursor(null, "prev"); - data[fieldName] = (cursor ? Number(cursor.key) + 1 : 1) as (typeof data)[typeof fieldName]; - } - } else { - data[fieldName] = defaultValue as (typeof data)[typeof fieldName]; - } - } - }), - ); - this.model.fields - .filter((field) => field.type === "DateTime") - .forEach((field) => { - const fieldName = field.name as keyof D; - if (typeof data[fieldName] === "string") { - data[fieldName] = new Date(data[fieldName]) as D[keyof D]; - } - }); - return data as unknown as Prisma.Result; - } - - async findMany>(query?: Q): Promise> { - const records = (await this.client.db.getAll(this.model.name)) as Prisma.Result[]; - return filterByWhereClause(records, this.keyPath, query?.where) as Prisma.Result; - } - - async findFirst>(query?: Q): Promise | null> { - return ((await this.findMany(query))[0] as Prisma.Result | undefined) ?? null; - } - - async findUnique>( - query: Q, - ): Promise | null> { - const queryWhere = query.where as Record; - if (this.model.primaryKey && this.model.primaryKey.fields.length > 1) { - const keyFieldValue = queryWhere[this.model.primaryKey.fields.join("_")] as Record; - const tupleKey = this.keyPath.map((key) => keyFieldValue[key]) as PrismaIDBSchema[typeof this.model.name]["key"]; - const foundRecord = await this.client.db.get(this.model.name, tupleKey); - if (!foundRecord) return null; - return ( - (filterByWhereClause([foundRecord], this.keyPath, query.where)[0] as Prisma.Result) ?? null - ); - } else { - const identifierFieldName = JSON.parse(generateIDBKey(this.model))[0]; - if (queryWhere[identifierFieldName]) { - return ((await this.client.db.get(this.model.name, [ - queryWhere[identifierFieldName], - ] as unknown as PrismaIDBSchema[typeof this.model.name]["key"])) ?? null) as Prisma.Result; - } - } - getModelFieldData(this.model) - .nonKeyUniqueFields.map(({ name }) => name) - .forEach(async (uniqueField) => { - { - if (!queryWhere[uniqueField]) return; - return ( - (await this.client.db.getFromIndex( - this.model.name, - `${uniqueField}Index`, - queryWhere[uniqueField] as IDBValidKey, - )) ?? null - ); - } - }); - throw new Error("No unique field provided for findUnique"); - } - - async create>(query: Q): Promise> { - const record = await this.fillDefaults(query.data); - await this.client.db.add(this.model.name, record); - this.emit("create"); - return record as Prisma.Result; - } - - async createMany>(query: Q): Promise> { - const tx = this.client.db.transaction(this.model.name, "readwrite"); - const queryData = Array.isArray(query.data) ? query.data : [query.data]; - await Promise.all([...queryData.map(async (record) => tx.store.add(await this.fillDefaults(record))), tx.done]); - this.emit("create"); - return { count: queryData.length } as Prisma.Result; - } - - async delete>(query: Q): Promise> { - const records = filterByWhereClause(await this.client.db.getAll(this.model.name), this.keyPath, query.where); - if (records.length === 0) throw new Error("Record not found"); - - await this.client.db.delete( - this.model.name, - this.keyPath.map( - (keyField) => records[0][keyField as keyof (typeof records)[number]] as IDBValidKey, - ) as PrismaIDBSchema[typeof this.model.name]["key"], - ); - this.emit("delete"); - return records[0] as Prisma.Result; - } - - async deleteMany>(query: Q): Promise> { - const records = filterByWhereClause(await this.client.db.getAll(this.model.name), this.keyPath, query?.where); - if (records.length === 0) return { count: 0 } as Prisma.Result; - - const tx = this.client.db.transaction(this.model.name, "readwrite"); - await Promise.all([ - ...records.map((record) => - tx.store.delete( - this.keyPath.map( - (keyField) => record[keyField as keyof typeof record] as IDBValidKey, - ) as PrismaIDBSchema[typeof this.model.name]["key"], - ), - ), - tx.done, - ]); - this.emit("delete"); - return { count: records.length } as Prisma.Result; - } - - async update>(query: Q): Promise> { - const record = await this.findFirst(query); - if (record === null) throw new Error("Record not found"); - - this.model.fields.forEach((field) => { - const fieldName = field.name as keyof typeof record & keyof typeof query.data; - if (query.data[fieldName] !== undefined) { - if (field.kind === "object") { - throw new Error("Object updates not yet supported"); - } else if (field.isList) { - throw new Error("List updates not yet supported"); - } else { - const fieldType = field.type as typeof prismaToJsTypes extends Map ? K : never; - const jsType = prismaToJsTypes.get(fieldType); - if (!jsType || jsType === "unknown") throw new Error(`Unsupported type: ${field.type}`); - - if (typeof query.data[fieldName] === jsType) { - record[fieldName] = query.data[fieldName]; - } else { - throw new Error("Indirect updates not yet supported"); - } - } - } - }); - await this.client.db.put(this.model.name, record); - this.emit("update"); - return record as Prisma.Result; - } - - async count>(query: Q): Promise> { - const records = filterByWhereClause(await this.client.db.getAll(this.model.name), this.keyPath, query?.where); - return records.length as Prisma.Result; - } -} diff --git a/packages/usage/src/lib/prisma-idb/utils.ts b/packages/usage/src/lib/prisma-idb/utils.ts deleted file mode 100644 index 1d0f31c..0000000 --- a/packages/usage/src/lib/prisma-idb/utils.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { DMMF } from "@prisma/client/runtime/library"; -import type { ModelDelegate } from "./prisma-idb-client"; - -export type Model = DMMF.Datamodel["models"][number]; - -export function intersectArraysByNestedKey>( - arrays: Prisma.Result[][], - keyPath: string[], -): Prisma.Result[] { - return arrays.reduce((acc, array) => - acc.filter((item) => - array.some((el) => - keyPath.every( - (key) => - el[key as keyof Prisma.Result] === - item[key as keyof Prisma.Result], - ), - ), - ), - ); -} - -export function getModelFieldData(model: Model) { - const keyPath = JSON.parse(generateIDBKey(model)); - const nonKeyUniqueFields = model.fields.filter(({ isUnique, name }) => isUnique && !keyPath.includes(name)); - const storeName = toCamelCase(model.name); - - const optionalFields = model.fields.filter((field) => !field.isRequired); - const fieldsWithDefaultValue = model.fields.filter((field) => field.hasDefaultValue); - const allRequiredFieldsHaveDefaults = fieldsWithDefaultValue.length === model.fields.length - optionalFields.length; - - return { optionalFields, fieldsWithDefaultValue, allRequiredFieldsHaveDefaults, nonKeyUniqueFields, storeName }; -} - -export function toCamelCase(str: string): string { - return str - .replace(/[_s-]+(.)?/g, (_, chr) => (chr ? chr.toUpperCase() : "")) - .replace(/^(.)/, (match) => match.toLowerCase()); -} - -export function generateIDBKey(model: Model) { - if (model.primaryKey) return JSON.stringify(model.primaryKey.fields); - - const idField = model.fields.find(({ isId }) => isId); - if (idField) return JSON.stringify([idField.name]); - - const uniqueField = model.fields.find(({ isUnique }) => isUnique)!; - return JSON.stringify([uniqueField.name]); -} - -export function removeDuplicatesByKeyPath>( - array: Prisma.Result[][], - keyPath: string[], -): Prisma.Result[] { - const seen = new Set(); - return array - .flatMap((el) => el) - .filter((item) => { - const key = JSON.stringify(keyPath.map((key) => item[key as keyof Prisma.Result])); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -export function isLogicalOperator(param: unknown) { - return param === "AND" || param === "OR" || param === "NOT"; -} - -export function filterByWhereClause>( - records: Prisma.Result[], - keyPath: string[], - whereClause: Prisma.Args["where"], -): Prisma.Result[] { - if (whereClause === undefined) return records; - - for (const [unsafeParam, value] of Object.entries(whereClause)) { - const param = unsafeParam as keyof Prisma.Result; - if (value === undefined) continue; - - if (isLogicalOperator(param)) { - const operands = Array.isArray(whereClause[param]) ? whereClause[param] : [whereClause[param]]; - - if (param === "AND") { - records = intersectArraysByNestedKey( - operands.map((operandClause) => filterByWhereClause(records, keyPath, operandClause)), - keyPath, - ); - } - - if (param === "OR") { - records = removeDuplicatesByKeyPath( - operands.map((operandClause) => filterByWhereClause(records, keyPath, operandClause)), - keyPath, - ); - } - - if (param === "NOT") { - const excludedRecords = removeDuplicatesByKeyPath( - operands.map((operandClause) => filterByWhereClause(records, keyPath, operandClause)), - keyPath, - ); - records = records.filter( - (item) => - !excludedRecords.some((excluded) => - keyPath.every( - (key) => - excluded[key as keyof Prisma.Result] === - item[key as keyof Prisma.Result], - ), - ), - ); - } - } - - records = records.filter((record) => { - // TODO: really hacky way to avoid checking for complex filters like gte and lte for now - const typedWhereClause = whereClause as Record; - const typedParam = param as string; - return record[param] === typedWhereClause[typedParam]; - }); - } - - return records; -} - -export const prismaToJsTypes = new Map([ - ["String", "string"], - ["Boolean", "boolean"], - ["Int", "number"], - ["BigInt", "bigint"], - ["Float", "number"], - ["Decimal", "string"], - ["DateTime", "Date"], - ["Json", "object"], - ["Bytes", "Buffer"], - ["Unsupported", "unknown"], -] as const); diff --git a/packages/usage/src/lib/prisma.ts b/packages/usage/src/lib/prisma.ts new file mode 100644 index 0000000..0426feb --- /dev/null +++ b/packages/usage/src/lib/prisma.ts @@ -0,0 +1,7 @@ +import { PrismaClient } from "@prisma/client"; + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; + +export const prisma = globalForPrisma.prisma || new PrismaClient(); + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; diff --git a/packages/usage/src/prisma/prisma-idb/idb-interface.ts b/packages/usage/src/prisma/prisma-idb/idb-interface.ts new file mode 100644 index 0000000..bcc20e6 --- /dev/null +++ b/packages/usage/src/prisma/prisma-idb/idb-interface.ts @@ -0,0 +1,24 @@ +import * as Prisma from "@prisma/client"; +import type { DBSchema } from "idb"; + +export interface PrismaIDBSchema extends DBSchema { + User: { + key: [id: Prisma.User["id"]]; + value: Prisma.User; + }; + Profile: { + key: [id: Prisma.Profile["id"]]; + value: Prisma.Profile; + indexes: { + userIdIndex: [userId: Prisma.Profile["userId"]]; + }; + }; + Post: { + key: [id: Prisma.Post["id"]]; + value: Prisma.Post; + }; + AllFieldScalarTypes: { + key: [id: Prisma.AllFieldScalarTypes["id"]]; + value: Prisma.AllFieldScalarTypes; + }; +} diff --git a/packages/usage/src/prisma/prisma-idb/idb-utils.ts b/packages/usage/src/prisma/prisma-idb/idb-utils.ts new file mode 100644 index 0000000..f787a27 --- /dev/null +++ b/packages/usage/src/prisma/prisma-idb/idb-utils.ts @@ -0,0 +1,335 @@ +import type { Prisma } from "@prisma/client"; +import type { IDBPTransaction, StoreNames } from "idb"; +import type { PrismaIDBSchema } from "./idb-interface"; + +export function convertToArray(arg: T | T[]): T[] { + return Array.isArray(arg) ? arg : [arg]; +} + +export type CreateTransactionType = IDBPTransaction[], "readwrite">; + +export function whereStringFilter>( + record: R, + fieldName: keyof R, + stringFilter: Prisma.StringFilter | Prisma.StringNullableFilter | string | undefined | null, +): boolean { + if (stringFilter === undefined) return true; + + const value = record[fieldName] as string | null; + if (stringFilter === null) return value === null; + + if (typeof stringFilter === "string") { + if (value !== stringFilter) return false; + } else { + if (stringFilter.equals === null) { + if (value !== null) return false; + } + if (typeof stringFilter.equals === "string") { + if (value === null) return false; + if (stringFilter.mode === "insensitive") { + if (stringFilter.equals.toLowerCase() !== value.toLowerCase()) return false; + } else { + if (stringFilter.equals !== value) return false; + } + } + if (stringFilter.not === null) { + if (value === null) return false; + } + if (typeof stringFilter.not === "string") { + if (value === null) return false; + if (stringFilter.mode === "insensitive") { + if (stringFilter.not.toLowerCase() === value.toLowerCase()) return false; + } else { + if (stringFilter.not === value) return false; + } + } + if (Array.isArray(stringFilter.in)) { + if (value === null) return false; + if (stringFilter.mode === "insensitive") { + if (!stringFilter.in.map((s) => s.toLowerCase()).includes(value.toLowerCase())) return false; + } else { + if (!stringFilter.in.includes(value)) return false; + } + } + if (Array.isArray(stringFilter.notIn)) { + if (value === null) return false; + if (stringFilter.mode === "insensitive") { + if (stringFilter.notIn.map((s) => s.toLowerCase()).includes(value.toLowerCase())) return false; + } else { + if (stringFilter.notIn.includes(value)) return false; + } + } + if (typeof stringFilter.lt === "string") { + if (value === null) return false; + if (!(value < stringFilter.lt)) return false; + } + if (typeof stringFilter.lte === "string") { + if (value === null) return false; + if (!(value <= stringFilter.lte)) return false; + } + if (typeof stringFilter.gt === "string") { + if (value === null) return false; + if (!(value > stringFilter.gt)) return false; + } + if (typeof stringFilter.gte === "string") { + if (value === null) return false; + if (!(value >= stringFilter.gte)) return false; + } + if (typeof stringFilter.contains === "string") { + if (value === null) return false; + if (stringFilter.mode === "insensitive") { + if (!value.toLowerCase().includes(stringFilter.contains.toLowerCase())) return false; + } else { + if (!value.includes(stringFilter.contains)) return false; + } + } + if (typeof stringFilter.startsWith === "string") { + if (value === null) return false; + if (stringFilter.mode === "insensitive") { + if (!value.toLowerCase().startsWith(stringFilter.startsWith.toLowerCase())) return false; + } else { + if (!value.startsWith(stringFilter.startsWith)) return false; + } + } + if (typeof stringFilter.endsWith === "string") { + if (value === null) return false; + if (stringFilter.mode === "insensitive") { + if (!value.toLowerCase().endsWith(stringFilter.endsWith.toLowerCase())) return false; + } else { + if (!value.endsWith(stringFilter.endsWith)) return false; + } + } + } + return true; +} + +export function whereNumberFilter>( + record: R, + fieldName: keyof R, + numberFilter: Prisma.IntFilter | Prisma.FloatFilter | number | undefined | null, +): boolean { + if (numberFilter === undefined) return true; + + const value = record[fieldName] as number | null; + if (numberFilter === null) return value === null; + + if (typeof numberFilter === "number") { + if (value !== numberFilter) return false; + } else { + if (numberFilter.equals === null) { + if (value !== null) return false; + } + if (typeof numberFilter.equals === "number") { + if (numberFilter.equals !== value) return false; + } + if (numberFilter.not === null) { + if (value === null) return false; + } + if (typeof numberFilter.not === "number") { + if (numberFilter.not === value) return false; + } + if (Array.isArray(numberFilter.in)) { + if (value === null) return false; + if (!numberFilter.in.includes(value)) return false; + } + if (Array.isArray(numberFilter.notIn)) { + if (value === null) return false; + if (numberFilter.notIn.includes(value)) return false; + } + if (typeof numberFilter.lt === "number") { + if (value === null) return false; + if (!(value < numberFilter.lt)) return false; + } + if (typeof numberFilter.lte === "number") { + if (value === null) return false; + if (!(value <= numberFilter.lte)) return false; + } + if (typeof numberFilter.gt === "number") { + if (value === null) return false; + if (!(value > numberFilter.gt)) return false; + } + if (typeof numberFilter.gte === "number") { + if (value === null) return false; + if (!(value >= numberFilter.gte)) return false; + } + } + return true; +} + +export function whereBigIntFilter>( + record: R, + fieldName: keyof R, + bigIntFilter: Prisma.BigIntFilter | number | bigint | undefined | null, +): boolean { + if (bigIntFilter === undefined) return true; + + const value = record[fieldName] as number | null; + if (bigIntFilter === null) return value === null; + + if (typeof bigIntFilter === "number" || typeof bigIntFilter === "bigint") { + if (value !== bigIntFilter) return false; + } else { + if (bigIntFilter.equals === null) { + if (value !== null) return false; + } + if (typeof bigIntFilter.equals === "number" || typeof bigIntFilter.equals === "bigint") { + if (bigIntFilter.equals != value) return false; + } + if (bigIntFilter.not === null) { + if (value === null) return false; + } + if (typeof bigIntFilter.not === "number" || typeof bigIntFilter.not === "bigint") { + if (bigIntFilter.not == value) return false; + } + if (Array.isArray(bigIntFilter.in)) { + if (value === null) return false; + if (!bigIntFilter.in.map((n) => BigInt(n)).includes(BigInt(value))) return false; + } + if (Array.isArray(bigIntFilter.notIn)) { + if (value === null) return false; + if (bigIntFilter.notIn.map((n) => BigInt(n)).includes(BigInt(value))) return false; + } + if (typeof bigIntFilter.lt === "number" || typeof bigIntFilter.lt === "bigint") { + if (value === null) return false; + if (!(value < bigIntFilter.lt)) return false; + } + if (typeof bigIntFilter.lte === "number" || typeof bigIntFilter.lte === "bigint") { + if (value === null) return false; + if (!(value <= bigIntFilter.lte)) return false; + } + if (typeof bigIntFilter.gt === "number" || typeof bigIntFilter.gt === "bigint") { + if (value === null) return false; + if (!(value > bigIntFilter.gt)) return false; + } + if (typeof bigIntFilter.gte === "number" || typeof bigIntFilter.gte === "bigint") { + if (value === null) return false; + if (!(value >= bigIntFilter.gte)) return false; + } + } + return true; +} + +export function whereBoolFilter>( + record: R, + fieldName: keyof R, + boolFilter: Prisma.BoolFilter | boolean | undefined | null, +): boolean { + if (boolFilter === undefined) return true; + + const value = record[fieldName] as boolean | null; + if (boolFilter === null) return value === null; + + if (typeof boolFilter === "boolean") { + if (value !== boolFilter) return false; + } else { + if (boolFilter.equals === null) { + if (value !== null) return false; + } + if (typeof boolFilter.equals === "boolean") { + if (boolFilter.equals != value) return false; + } + if (boolFilter.not === null) { + if (value === null) return false; + } + if (typeof boolFilter.not === "boolean") { + if (boolFilter.not == value) return false; + } + } + return true; +} + +export function whereBytesFilter>( + record: R, + fieldName: keyof R, + bytesFilter: Prisma.BytesFilter | Buffer | undefined | null, +): boolean { + if (bytesFilter === undefined) return true; + + const value = record[fieldName] as Buffer | null; + if (bytesFilter === null) return value === null; + + if (Buffer.isBuffer(bytesFilter)) { + if (value === null) return false; + if (!bytesFilter.equals(value)) return false; + } else { + if (bytesFilter.equals === null) { + if (value !== null) return false; + } + if (Buffer.isBuffer(bytesFilter.equals)) { + if (value === null) return false; + if (!bytesFilter.equals.equals(value)) return false; + } + if (bytesFilter.not === null) { + if (value === null) return false; + } + if (Buffer.isBuffer(bytesFilter.not)) { + if (value === null) return false; + if (bytesFilter.not.equals(value)) return false; + } + if (Array.isArray(bytesFilter.in)) { + if (value === null) return false; + if (!bytesFilter.in.some((buffer) => buffer.equals(value))) return false; + } + if (Array.isArray(bytesFilter.notIn)) { + if (value === null) return false; + if (bytesFilter.notIn.some((buffer) => buffer.equals(value))) return false; + } + } + return true; +} + +export function whereDateTimeFilter>( + record: R, + fieldName: keyof R, + dateTimeFilter: string | Prisma.DateTimeFilter | Date | undefined, +): boolean { + if (dateTimeFilter === undefined) return true; + + const value = record[fieldName] as Date | null; + if (dateTimeFilter === null) return value === null; + + if (typeof dateTimeFilter === "string" || dateTimeFilter instanceof Date) { + if (value === null) return false; + if (new Date(dateTimeFilter).getTime() !== value.getTime()) return false; + } else { + if (dateTimeFilter.equals === null) { + if (value !== null) return false; + } + if (typeof dateTimeFilter.equals === "string" || dateTimeFilter.equals instanceof Date) { + if (value === null) return false; + if (new Date(dateTimeFilter.equals).getTime() !== value.getTime()) return false; + } + if (dateTimeFilter.not === null) { + if (value === null) return false; + } + if (typeof dateTimeFilter.equals === "string" || dateTimeFilter.equals instanceof Date) { + if (value === null) return false; + if (new Date(dateTimeFilter.equals).getTime() === value.getTime()) return false; + } + if (Array.isArray(dateTimeFilter.in)) { + if (value === null) return false; + if (!dateTimeFilter.in.map((d) => new Date(d)).some((d) => d.getTime() === value.getTime())) return false; + } + if (Array.isArray(dateTimeFilter.notIn)) { + if (value === null) return false; + if (dateTimeFilter.notIn.map((d) => new Date(d)).some((d) => d.getTime() === value.getTime())) return false; + } + if (typeof dateTimeFilter.lt === "string" || dateTimeFilter.lt instanceof Date) { + if (value === null) return false; + if (!(value.getTime() < new Date(dateTimeFilter.lt).getTime())) return false; + } + if (typeof dateTimeFilter.lte === "string" || dateTimeFilter.lte instanceof Date) { + if (value === null) return false; + if (!(value.getTime() <= new Date(dateTimeFilter.lte).getTime())) return false; + } + if (typeof dateTimeFilter.gt === "string" || dateTimeFilter.gt instanceof Date) { + if (value === null) return false; + if (!(value.getTime() > new Date(dateTimeFilter.gt).getTime())) return false; + } + if (typeof dateTimeFilter.gte === "string" || dateTimeFilter.gte instanceof Date) { + if (value === null) return false; + if (!(value.getTime() >= new Date(dateTimeFilter.gte).getTime())) return false; + } + } + return true; +} diff --git a/packages/usage/src/prisma/prisma-idb/prisma-idb-client.ts b/packages/usage/src/prisma/prisma-idb/prisma-idb-client.ts new file mode 100644 index 0000000..7738d80 --- /dev/null +++ b/packages/usage/src/prisma/prisma-idb/prisma-idb-client.ts @@ -0,0 +1,1068 @@ +import type { Prisma } from "@prisma/client"; +import type { IDBPDatabase, StoreNames } from "idb"; +import { openDB } from "idb"; +import type { PrismaIDBSchema } from "./idb-interface"; +import type { CreateTransactionType } from "./idb-utils"; +import { + convertToArray, + whereBigIntFilter, + whereBoolFilter, + whereBytesFilter, + whereDateTimeFilter, + whereNumberFilter, + whereStringFilter, +} from "./idb-utils"; + +/* eslint-disable @typescript-eslint/no-unused-vars */ +const IDB_VERSION = 1; + +export class PrismaIDBClient { + private static instance: PrismaIDBClient; + _db!: IDBPDatabase; + + private constructor() {} + + user!: UserIDBClass; + profile!: ProfileIDBClass; + post!: PostIDBClass; + allFieldScalarTypes!: AllFieldScalarTypesIDBClass; + + public static async create(): Promise { + if (!PrismaIDBClient.instance) { + const client = new PrismaIDBClient(); + await client.initialize(); + PrismaIDBClient.instance = client; + } + return PrismaIDBClient.instance; + } + + private async initialize() { + this._db = await openDB("prisma-idb", IDB_VERSION, { + upgrade(db) { + db.createObjectStore("User", { keyPath: ["id"] }); + const ProfileStore = db.createObjectStore("Profile", { keyPath: ["id"] }); + ProfileStore.createIndex("userIdIndex", ["userId"], { unique: true }); + db.createObjectStore("Post", { keyPath: ["id"] }); + db.createObjectStore("AllFieldScalarTypes", { keyPath: ["id"] }); + }, + }); + this.user = new UserIDBClass(this, ["id"]); + this.profile = new ProfileIDBClass(this, ["id"]); + this.post = new PostIDBClass(this, ["id"]); + this.allFieldScalarTypes = new AllFieldScalarTypesIDBClass(this, ["id"]); + } +} + +class BaseIDBModelClass { + protected client: PrismaIDBClient; + protected keyPath: string[]; + private eventEmitter: EventTarget; + + constructor(client: PrismaIDBClient, keyPath: string[]) { + this.client = client; + this.keyPath = keyPath; + this.eventEmitter = new EventTarget(); + } + + subscribe(event: "create" | "update" | "delete" | ("create" | "update" | "delete")[], callback: () => void) { + if (Array.isArray(event)) { + event.forEach((event) => this.eventEmitter.addEventListener(event, callback)); + return; + } + this.eventEmitter.addEventListener(event, callback); + } + + unsubscribe(event: "create" | "update" | "delete" | ("create" | "update" | "delete")[], callback: () => void) { + if (Array.isArray(event)) { + event.forEach((event) => this.eventEmitter.removeEventListener(event, callback)); + return; + } + this.eventEmitter.removeEventListener(event, callback); + } + + protected emit(event: "create" | "update" | "delete") { + this.eventEmitter.dispatchEvent(new Event(event)); + } +} + +class UserIDBClass extends BaseIDBModelClass { + private async _applyWhereClause< + W extends Prisma.Args["where"], + R extends Prisma.Result, + >(records: R[], whereClause: W): Promise { + if (!whereClause) return records; + return records.filter((record) => { + const stringFields = ["name"] as const; + for (const field of stringFields) { + if (!whereStringFilter(record, field, whereClause[field])) return false; + } + const numberFields = ["id"] as const; + for (const field of numberFields) { + if (!whereNumberFilter(record, field, whereClause[field])) return false; + } + return true; + }); + } + + private _applySelectClause["select"]>( + records: Prisma.Result[], + selectClause: S, + ): Prisma.Result[] { + if (!selectClause) { + return records as Prisma.Result[]; + } + return records.map((record) => { + const partialRecord: Partial = record; + for (const untypedKey of ["id", "name", "profile", "posts"]) { + const key = untypedKey as keyof typeof record & keyof S; + if (!selectClause[key]) delete partialRecord[key]; + } + return partialRecord; + }) as Prisma.Result[]; + } + + private async _applyRelations>( + records: Prisma.Result[], + query?: Q, + ): Promise[]> { + if (!query) return records as Prisma.Result[]; + const recordsWithRelations = records.map(async (record) => { + const unsafeRecord = record as Record; + const attach_profile = query.select?.profile || query.include?.profile; + if (attach_profile) { + unsafeRecord["profile"] = await this.client.profile.findUnique({ + ...(attach_profile === true ? {} : attach_profile), + where: { userId: record.id }, + }); + } + const attach_posts = query.select?.posts || query.include?.posts; + if (attach_posts) { + unsafeRecord["posts"] = await this.client.post.findMany({ + ...(attach_posts === true ? {} : attach_posts), + where: { authorId: record.id }, + }); + } + return unsafeRecord; + }); + return (await Promise.all(recordsWithRelations)) as Prisma.Result[]; + } + + private async _fillDefaults["data"]>( + data: D, + tx?: CreateTransactionType, + ): Promise> { + if (data === undefined) data = {} as NonNullable; + if (data.id === undefined) { + const transaction = tx ?? this.client._db.transaction(["User"], "readwrite"); + const store = transaction.objectStore("User"); + const cursor = await store.openCursor(null, "prev"); + data.id = cursor ? Number(cursor.key) + 1 : 1; + } + return data as Prisma.Result; + } + + _getNeededStoresForCreate["data"]>>( + data: D, + ): Set> { + const neededStores: Set> = new Set(); + if (data.profile) { + neededStores.add("Profile"); + if (data.profile.create) { + convertToArray(data.profile.create).forEach((record) => + this.client.profile._getNeededStoresForCreate(record).forEach((storeName) => neededStores.add(storeName)), + ); + } + if (data.profile.connectOrCreate) { + convertToArray(data.profile.connectOrCreate).forEach((record) => + this.client.profile + ._getNeededStoresForCreate(record.create) + .forEach((storeName) => neededStores.add(storeName)), + ); + } + } + if (data.posts) { + neededStores.add("Post"); + if (data.posts.create) { + convertToArray(data.posts.create).forEach((record) => + this.client.post._getNeededStoresForCreate(record).forEach((storeName) => neededStores.add(storeName)), + ); + } + if (data.posts.connectOrCreate) { + convertToArray(data.posts.connectOrCreate).forEach((record) => + this.client.post._getNeededStoresForCreate(record.create).forEach((storeName) => neededStores.add(storeName)), + ); + } + if (data.posts.createMany) { + convertToArray(data.posts.createMany.data).forEach((record) => + this.client.post._getNeededStoresForCreate(record).forEach((storeName) => neededStores.add(storeName)), + ); + } + } + return neededStores; + } + + private _removeNestedCreateData["data"]>( + data: D, + ): Prisma.Result { + const recordWithoutNestedCreate = structuredClone(data); + delete recordWithoutNestedCreate.profile; + delete recordWithoutNestedCreate.posts; + return recordWithoutNestedCreate as Prisma.Result; + } + + private async _performNestedCreates["data"]>( + data: D, + tx: CreateTransactionType, + ) { + if (data.profile) { + if (data.profile.create) { + await this.client.profile._nestedCreate( + { + data: { ...data.profile.create, userId: data.id! }, + }, + tx, + ); + } + if (data.profile.connectOrCreate) { + throw new Error("connectOrCreate not yet implemented"); + } + delete data.profile; + } + if (data.posts) { + if (data.posts.create) { + await this.client.post.createMany( + { + data: convertToArray(data.posts.create).map((createData) => ({ + ...createData, + authorId: data.id!, + })), + }, + tx, + ); + } + if (data.posts.connectOrCreate) { + throw new Error("connectOrCreate not yet implemented"); + } + if (data.posts.createMany) { + await this.client.post.createMany( + { + data: convertToArray(data.posts.createMany.data).map((createData) => ({ + ...createData, + authorId: data.id!, + })), + }, + tx, + ); + } + delete data.posts; + } + } + + async _nestedCreate>( + query: Q, + tx: CreateTransactionType, + ): Promise { + await this._performNestedCreates(query.data, tx); + const record = await this._fillDefaults(query.data, tx); + const keyPath = await tx.objectStore("User").add(record); + return keyPath; + } + + async findMany>( + query?: Q, + ): Promise> { + const records = await this._applyWhereClause(await this.client._db.getAll("User"), query?.where); + const relationAppliedRecords = (await this._applyRelations(records, query)) as Prisma.Result< + Prisma.UserDelegate, + object, + "findFirstOrThrow" + >[]; + const selectClause = query?.select; + const selectAppliedRecords = this._applySelectClause(relationAppliedRecords, selectClause); + return selectAppliedRecords as Prisma.Result; + } + + async findFirst>( + query?: Q, + ): Promise> { + return (await this.findMany(query))[0]; + } + + async findFirstOrThrow>( + query?: Q, + ): Promise> { + const record = await this.findFirst(query); + if (!record) throw new Error("Record not found"); + return record; + } + + async findUnique>( + query: Q, + ): Promise> { + let record; + if (query.where.id) { + record = await this.client._db.get("User", [query.where.id]); + } + if (!record) return null; + + const recordWithRelations = this._applySelectClause( + await this._applyRelations(await this._applyWhereClause([record], query.where), query), + query.select, + )[0]; + return recordWithRelations as Prisma.Result; + } + + async findUniqueOrThrow>( + query: Q, + ): Promise> { + const record = await this.findUnique(query); + if (!record) throw new Error("Record not found"); + return record; + } + + async count>( + query?: Q, + ): Promise> { + if (!query?.select || query.select === true) { + const records = await this.findMany({ where: query?.where }); + return records.length as Prisma.Result; + } + const result: Partial> = {}; + for (const key of Object.keys(query.select)) { + const typedKey = key as keyof typeof query.select; + if (typedKey === "_all") { + result[typedKey] = (await this.findMany({ where: query.where })).length; + continue; + } + result[typedKey] = (await this.findMany({ where: { [`${typedKey}`]: { not: null } } })).length; + } + return result as Prisma.Result; + } + + async create>( + query: Q, + ): Promise> { + const record = await this._fillDefaults(query.data); + let keyPath: PrismaIDBSchema["User"]["key"]; + const storesNeeded = this._getNeededStoresForCreate(query.data); + if (storesNeeded.size === 0) { + keyPath = await this.client._db.add("User", record); + } else { + const tx = this.client._db.transaction(["User", ...Array.from(storesNeeded)], "readwrite"); + await this._performNestedCreates(query.data, tx); + keyPath = await tx.objectStore("User").add(this._removeNestedCreateData(query.data)); + tx.commit(); + } + const data = (await this.client._db.get("User", keyPath))!; + const recordsWithRelations = this._applySelectClause(await this._applyRelations([data], query), query.select)[0]; + return recordsWithRelations as Prisma.Result; + } + + async createMany>( + query: Q, + tx?: CreateTransactionType, + ): Promise> { + const createManyData = convertToArray(query.data); + tx = tx ?? this.client._db.transaction(["User"], "readwrite"); + for (const createData of createManyData) { + const record = await this._fillDefaults(createData, tx); + await tx.objectStore("User").add(record); + } + return { count: createManyData.length }; + } +} + +class ProfileIDBClass extends BaseIDBModelClass { + private async _applyWhereClause< + W extends Prisma.Args["where"], + R extends Prisma.Result, + >(records: R[], whereClause: W): Promise { + if (!whereClause) return records; + return records.filter((record) => { + const stringFields = ["bio"] as const; + for (const field of stringFields) { + if (!whereStringFilter(record, field, whereClause[field])) return false; + } + const numberFields = ["id", "userId"] as const; + for (const field of numberFields) { + if (!whereNumberFilter(record, field, whereClause[field])) return false; + } + return true; + }); + } + + private _applySelectClause["select"]>( + records: Prisma.Result[], + selectClause: S, + ): Prisma.Result[] { + if (!selectClause) { + return records as Prisma.Result[]; + } + return records.map((record) => { + const partialRecord: Partial = record; + for (const untypedKey of ["id", "bio", "user", "userId"]) { + const key = untypedKey as keyof typeof record & keyof S; + if (!selectClause[key]) delete partialRecord[key]; + } + return partialRecord; + }) as Prisma.Result[]; + } + + private async _applyRelations>( + records: Prisma.Result[], + query?: Q, + ): Promise[]> { + if (!query) return records as Prisma.Result[]; + const recordsWithRelations = records.map(async (record) => { + const unsafeRecord = record as Record; + const attach_user = query.select?.user || query.include?.user; + if (attach_user) { + unsafeRecord["user"] = await this.client.user.findUnique({ + ...(attach_user === true ? {} : attach_user), + where: { id: record.userId }, + }); + } + return unsafeRecord; + }); + return (await Promise.all(recordsWithRelations)) as Prisma.Result[]; + } + + private async _fillDefaults["data"]>( + data: D, + tx?: CreateTransactionType, + ): Promise> { + if (data === undefined) data = {} as NonNullable; + if (data.id === undefined) { + const transaction = tx ?? this.client._db.transaction(["Profile"], "readwrite"); + const store = transaction.objectStore("Profile"); + const cursor = await store.openCursor(null, "prev"); + data.id = cursor ? Number(cursor.key) + 1 : 1; + } + if (data.bio === undefined) { + data.bio = null; + } + return data as Prisma.Result; + } + + _getNeededStoresForCreate["data"]>>( + data: D, + ): Set> { + const neededStores: Set> = new Set(); + if (data.user) { + neededStores.add("User"); + if (data.user.create) { + convertToArray(data.user.create).forEach((record) => + this.client.user._getNeededStoresForCreate(record).forEach((storeName) => neededStores.add(storeName)), + ); + } + if (data.user.connectOrCreate) { + convertToArray(data.user.connectOrCreate).forEach((record) => + this.client.user._getNeededStoresForCreate(record.create).forEach((storeName) => neededStores.add(storeName)), + ); + } + } + return neededStores; + } + + private _removeNestedCreateData["data"]>( + data: D, + ): Prisma.Result { + const recordWithoutNestedCreate = structuredClone(data); + delete recordWithoutNestedCreate.user; + return recordWithoutNestedCreate as Prisma.Result; + } + + private async _performNestedCreates["data"]>( + data: D, + tx: CreateTransactionType, + ) { + if (data.user) { + let fk; + if (data.user.create) { + fk = (await this.client.user._nestedCreate({ data: data.user.create }, tx))[0]; + } + if (data.user.connectOrCreate) { + throw new Error("connectOrCreate not yet implemented"); + } + const unsafeData = data as Record; + unsafeData.userId = fk as NonNullable; + delete unsafeData.user; + } + } + + async _nestedCreate>( + query: Q, + tx: CreateTransactionType, + ): Promise { + await this._performNestedCreates(query.data, tx); + const record = await this._fillDefaults(query.data, tx); + const keyPath = await tx.objectStore("Profile").add(record); + return keyPath; + } + + async findMany>( + query?: Q, + ): Promise> { + const records = await this._applyWhereClause(await this.client._db.getAll("Profile"), query?.where); + const relationAppliedRecords = (await this._applyRelations(records, query)) as Prisma.Result< + Prisma.ProfileDelegate, + object, + "findFirstOrThrow" + >[]; + const selectClause = query?.select; + const selectAppliedRecords = this._applySelectClause(relationAppliedRecords, selectClause); + return selectAppliedRecords as Prisma.Result; + } + + async findFirst>( + query?: Q, + ): Promise> { + return (await this.findMany(query))[0]; + } + + async findFirstOrThrow>( + query?: Q, + ): Promise> { + const record = await this.findFirst(query); + if (!record) throw new Error("Record not found"); + return record; + } + + async findUnique>( + query: Q, + ): Promise> { + let record; + if (query.where.id) { + record = await this.client._db.get("Profile", [query.where.id]); + } else if (query.where.userId) { + record = await this.client._db.getFromIndex("Profile", "userIdIndex", [query.where.userId]); + } + if (!record) return null; + + const recordWithRelations = this._applySelectClause( + await this._applyRelations(await this._applyWhereClause([record], query.where), query), + query.select, + )[0]; + return recordWithRelations as Prisma.Result; + } + + async findUniqueOrThrow>( + query: Q, + ): Promise> { + const record = await this.findUnique(query); + if (!record) throw new Error("Record not found"); + return record; + } + + async count>( + query?: Q, + ): Promise> { + if (!query?.select || query.select === true) { + const records = await this.findMany({ where: query?.where }); + return records.length as Prisma.Result; + } + const result: Partial> = {}; + for (const key of Object.keys(query.select)) { + const typedKey = key as keyof typeof query.select; + if (typedKey === "_all") { + result[typedKey] = (await this.findMany({ where: query.where })).length; + continue; + } + result[typedKey] = (await this.findMany({ where: { [`${typedKey}`]: { not: null } } })).length; + } + return result as Prisma.Result; + } + + async create>( + query: Q, + ): Promise> { + const record = await this._fillDefaults(query.data); + let keyPath: PrismaIDBSchema["Profile"]["key"]; + const storesNeeded = this._getNeededStoresForCreate(query.data); + if (storesNeeded.size === 0) { + keyPath = await this.client._db.add("Profile", record); + } else { + const tx = this.client._db.transaction(["Profile", ...Array.from(storesNeeded)], "readwrite"); + await this._performNestedCreates(query.data, tx); + keyPath = await tx.objectStore("Profile").add(this._removeNestedCreateData(query.data)); + tx.commit(); + } + const data = (await this.client._db.get("Profile", keyPath))!; + const recordsWithRelations = this._applySelectClause(await this._applyRelations([data], query), query.select)[0]; + return recordsWithRelations as Prisma.Result; + } + + async createMany>( + query: Q, + tx?: CreateTransactionType, + ): Promise> { + const createManyData = convertToArray(query.data); + tx = tx ?? this.client._db.transaction(["Profile"], "readwrite"); + for (const createData of createManyData) { + const record = await this._fillDefaults(createData, tx); + await tx.objectStore("Profile").add(record); + } + return { count: createManyData.length }; + } +} + +class PostIDBClass extends BaseIDBModelClass { + private async _applyWhereClause< + W extends Prisma.Args["where"], + R extends Prisma.Result, + >(records: R[], whereClause: W): Promise { + if (!whereClause) return records; + return records.filter((record) => { + const stringFields = ["title"] as const; + for (const field of stringFields) { + if (!whereStringFilter(record, field, whereClause[field])) return false; + } + const numberFields = ["id", "authorId"] as const; + for (const field of numberFields) { + if (!whereNumberFilter(record, field, whereClause[field])) return false; + } + return true; + }); + } + + private _applySelectClause["select"]>( + records: Prisma.Result[], + selectClause: S, + ): Prisma.Result[] { + if (!selectClause) { + return records as Prisma.Result[]; + } + return records.map((record) => { + const partialRecord: Partial = record; + for (const untypedKey of ["id", "title", "author", "authorId"]) { + const key = untypedKey as keyof typeof record & keyof S; + if (!selectClause[key]) delete partialRecord[key]; + } + return partialRecord; + }) as Prisma.Result[]; + } + + private async _applyRelations>( + records: Prisma.Result[], + query?: Q, + ): Promise[]> { + if (!query) return records as Prisma.Result[]; + const recordsWithRelations = records.map(async (record) => { + const unsafeRecord = record as Record; + const attach_author = query.select?.author || query.include?.author; + if (attach_author) { + unsafeRecord["author"] = await this.client.user.findUnique({ + ...(attach_author === true ? {} : attach_author), + where: { id: record.authorId }, + }); + } + return unsafeRecord; + }); + return (await Promise.all(recordsWithRelations)) as Prisma.Result[]; + } + + private async _fillDefaults["data"]>( + data: D, + tx?: CreateTransactionType, + ): Promise> { + if (data === undefined) data = {} as NonNullable; + if (data.id === undefined) { + const transaction = tx ?? this.client._db.transaction(["Post"], "readwrite"); + const store = transaction.objectStore("Post"); + const cursor = await store.openCursor(null, "prev"); + data.id = cursor ? Number(cursor.key) + 1 : 1; + } + return data as Prisma.Result; + } + + _getNeededStoresForCreate["data"]>>( + data: D, + ): Set> { + const neededStores: Set> = new Set(); + if (data.author) { + neededStores.add("User"); + if (data.author.create) { + convertToArray(data.author.create).forEach((record) => + this.client.user._getNeededStoresForCreate(record).forEach((storeName) => neededStores.add(storeName)), + ); + } + if (data.author.connectOrCreate) { + convertToArray(data.author.connectOrCreate).forEach((record) => + this.client.user._getNeededStoresForCreate(record.create).forEach((storeName) => neededStores.add(storeName)), + ); + } + } + return neededStores; + } + + private _removeNestedCreateData["data"]>( + data: D, + ): Prisma.Result { + const recordWithoutNestedCreate = structuredClone(data); + delete recordWithoutNestedCreate.author; + return recordWithoutNestedCreate as Prisma.Result; + } + + private async _performNestedCreates["data"]>( + data: D, + tx: CreateTransactionType, + ) { + if (data.author) { + let fk; + if (data.author.create) { + fk = (await this.client.user._nestedCreate({ data: data.author.create }, tx))[0]; + } + if (data.author.connectOrCreate) { + throw new Error("connectOrCreate not yet implemented"); + } + const unsafeData = data as Record; + unsafeData.userId = fk as NonNullable; + delete unsafeData.author; + } + } + + async _nestedCreate>( + query: Q, + tx: CreateTransactionType, + ): Promise { + await this._performNestedCreates(query.data, tx); + const record = await this._fillDefaults(query.data, tx); + const keyPath = await tx.objectStore("Post").add(record); + return keyPath; + } + + async findMany>( + query?: Q, + ): Promise> { + const records = await this._applyWhereClause(await this.client._db.getAll("Post"), query?.where); + const relationAppliedRecords = (await this._applyRelations(records, query)) as Prisma.Result< + Prisma.PostDelegate, + object, + "findFirstOrThrow" + >[]; + const selectClause = query?.select; + const selectAppliedRecords = this._applySelectClause(relationAppliedRecords, selectClause); + return selectAppliedRecords as Prisma.Result; + } + + async findFirst>( + query?: Q, + ): Promise> { + return (await this.findMany(query))[0]; + } + + async findFirstOrThrow>( + query?: Q, + ): Promise> { + const record = await this.findFirst(query); + if (!record) throw new Error("Record not found"); + return record; + } + + async findUnique>( + query: Q, + ): Promise> { + let record; + if (query.where.id) { + record = await this.client._db.get("Post", [query.where.id]); + } + if (!record) return null; + + const recordWithRelations = this._applySelectClause( + await this._applyRelations(await this._applyWhereClause([record], query.where), query), + query.select, + )[0]; + return recordWithRelations as Prisma.Result; + } + + async findUniqueOrThrow>( + query: Q, + ): Promise> { + const record = await this.findUnique(query); + if (!record) throw new Error("Record not found"); + return record; + } + + async count>( + query?: Q, + ): Promise> { + if (!query?.select || query.select === true) { + const records = await this.findMany({ where: query?.where }); + return records.length as Prisma.Result; + } + const result: Partial> = {}; + for (const key of Object.keys(query.select)) { + const typedKey = key as keyof typeof query.select; + if (typedKey === "_all") { + result[typedKey] = (await this.findMany({ where: query.where })).length; + continue; + } + result[typedKey] = (await this.findMany({ where: { [`${typedKey}`]: { not: null } } })).length; + } + return result as Prisma.Result; + } + + async create>( + query: Q, + ): Promise> { + const record = await this._fillDefaults(query.data); + let keyPath: PrismaIDBSchema["Post"]["key"]; + const storesNeeded = this._getNeededStoresForCreate(query.data); + if (storesNeeded.size === 0) { + keyPath = await this.client._db.add("Post", record); + } else { + const tx = this.client._db.transaction(["Post", ...Array.from(storesNeeded)], "readwrite"); + await this._performNestedCreates(query.data, tx); + keyPath = await tx.objectStore("Post").add(this._removeNestedCreateData(query.data)); + tx.commit(); + } + const data = (await this.client._db.get("Post", keyPath))!; + const recordsWithRelations = this._applySelectClause(await this._applyRelations([data], query), query.select)[0]; + return recordsWithRelations as Prisma.Result; + } + + async createMany>( + query: Q, + tx?: CreateTransactionType, + ): Promise> { + const createManyData = convertToArray(query.data); + tx = tx ?? this.client._db.transaction(["Post"], "readwrite"); + for (const createData of createManyData) { + const record = await this._fillDefaults(createData, tx); + await tx.objectStore("Post").add(record); + } + return { count: createManyData.length }; + } +} + +class AllFieldScalarTypesIDBClass extends BaseIDBModelClass { + private async _applyWhereClause< + W extends Prisma.Args["where"], + R extends Prisma.Result, + >(records: R[], whereClause: W): Promise { + if (!whereClause) return records; + return records.filter((record) => { + const stringFields = ["string"] as const; + for (const field of stringFields) { + if (!whereStringFilter(record, field, whereClause[field])) return false; + } + const numberFields = ["id", "int", "float"] as const; + for (const field of numberFields) { + if (!whereNumberFilter(record, field, whereClause[field])) return false; + } + const bigIntFields = ["bigInt"] as const; + for (const field of bigIntFields) { + if (!whereBigIntFilter(record, field, whereClause[field])) return false; + } + const booleanFields = ["boolean"] as const; + for (const field of booleanFields) { + if (!whereBoolFilter(record, field, whereClause[field])) return false; + } + const bytesFields = ["bytes"] as const; + for (const field of bytesFields) { + if (!whereBytesFilter(record, field, whereClause[field])) return false; + } + const dateTimeFields = ["dateTime"] as const; + for (const field of dateTimeFields) { + if (!whereDateTimeFilter(record, field, whereClause[field])) return false; + } + return true; + }); + } + + private _applySelectClause["select"]>( + records: Prisma.Result[], + selectClause: S, + ): Prisma.Result[] { + if (!selectClause) { + return records as Prisma.Result[]; + } + return records.map((record) => { + const partialRecord: Partial = record; + for (const untypedKey of [ + "id", + "string", + "boolean", + "int", + "bigInt", + "float", + "decimal", + "dateTime", + "json", + "bytes", + ]) { + const key = untypedKey as keyof typeof record & keyof S; + if (!selectClause[key]) delete partialRecord[key]; + } + return partialRecord; + }) as Prisma.Result[]; + } + + private async _applyRelations>( + records: Prisma.Result[], + query?: Q, + ): Promise[]> { + if (!query) return records as Prisma.Result[]; + const recordsWithRelations = records.map(async (record) => { + const unsafeRecord = record as Record; + return unsafeRecord; + }); + return (await Promise.all(recordsWithRelations)) as Prisma.Result< + Prisma.AllFieldScalarTypesDelegate, + Q, + "findFirstOrThrow" + >[]; + } + + private async _fillDefaults["data"]>( + data: D, + tx?: CreateTransactionType, + ): Promise> { + if (data === undefined) data = {} as NonNullable; + if (data.id === undefined) { + const transaction = tx ?? this.client._db.transaction(["AllFieldScalarTypes"], "readwrite"); + const store = transaction.objectStore("AllFieldScalarTypes"); + const cursor = await store.openCursor(null, "prev"); + data.id = cursor ? Number(cursor.key) + 1 : 1; + } + if (typeof data.bigInt === "number") { + data.bigInt = BigInt(data.bigInt); + } + if (typeof data.dateTime === "string") { + data.dateTime = new Date(data.dateTime); + } + return data as Prisma.Result; + } + + _getNeededStoresForCreate["data"]>>( + data: D, + ): Set> { + const neededStores: Set> = new Set(); + return neededStores; + } + + private _removeNestedCreateData["data"]>( + data: D, + ): Prisma.Result { + const recordWithoutNestedCreate = structuredClone(data); + return recordWithoutNestedCreate as Prisma.Result; + } + + private async _performNestedCreates["data"]>( + data: D, + tx: CreateTransactionType, + ) {} + + async _nestedCreate>( + query: Q, + tx: CreateTransactionType, + ): Promise { + await this._performNestedCreates(query.data, tx); + const record = await this._fillDefaults(query.data, tx); + const keyPath = await tx.objectStore("AllFieldScalarTypes").add(record); + return keyPath; + } + + async findMany>( + query?: Q, + ): Promise> { + const records = await this._applyWhereClause(await this.client._db.getAll("AllFieldScalarTypes"), query?.where); + const relationAppliedRecords = (await this._applyRelations(records, query)) as Prisma.Result< + Prisma.AllFieldScalarTypesDelegate, + object, + "findFirstOrThrow" + >[]; + const selectClause = query?.select; + const selectAppliedRecords = this._applySelectClause(relationAppliedRecords, selectClause); + return selectAppliedRecords as Prisma.Result; + } + + async findFirst>( + query?: Q, + ): Promise> { + return (await this.findMany(query))[0]; + } + + async findFirstOrThrow>( + query?: Q, + ): Promise> { + const record = await this.findFirst(query); + if (!record) throw new Error("Record not found"); + return record; + } + + async findUnique>( + query: Q, + ): Promise> { + let record; + if (query.where.id) { + record = await this.client._db.get("AllFieldScalarTypes", [query.where.id]); + } + if (!record) return null; + + const recordWithRelations = this._applySelectClause( + await this._applyRelations(await this._applyWhereClause([record], query.where), query), + query.select, + )[0]; + return recordWithRelations as Prisma.Result; + } + + async findUniqueOrThrow>( + query: Q, + ): Promise> { + const record = await this.findUnique(query); + if (!record) throw new Error("Record not found"); + return record; + } + + async count>( + query?: Q, + ): Promise> { + if (!query?.select || query.select === true) { + const records = await this.findMany({ where: query?.where }); + return records.length as Prisma.Result; + } + const result: Partial> = {}; + for (const key of Object.keys(query.select)) { + const typedKey = key as keyof typeof query.select; + if (typedKey === "_all") { + result[typedKey] = (await this.findMany({ where: query.where })).length; + continue; + } + result[typedKey] = (await this.findMany({ where: { [`${typedKey}`]: { not: null } } })).length; + } + return result as Prisma.Result; + } + + async create>( + query: Q, + ): Promise> { + const record = await this._fillDefaults(query.data); + let keyPath: PrismaIDBSchema["AllFieldScalarTypes"]["key"]; + const storesNeeded = this._getNeededStoresForCreate(query.data); + if (storesNeeded.size === 0) { + keyPath = await this.client._db.add("AllFieldScalarTypes", record); + } else { + const tx = this.client._db.transaction(["AllFieldScalarTypes", ...Array.from(storesNeeded)], "readwrite"); + await this._performNestedCreates(query.data, tx); + keyPath = await tx.objectStore("AllFieldScalarTypes").add(this._removeNestedCreateData(query.data)); + tx.commit(); + } + const data = (await this.client._db.get("AllFieldScalarTypes", keyPath))!; + const recordsWithRelations = this._applySelectClause(await this._applyRelations([data], query), query.select)[0]; + return recordsWithRelations as Prisma.Result; + } + + async createMany>( + query: Q, + tx?: CreateTransactionType, + ): Promise> { + const createManyData = convertToArray(query.data); + tx = tx ?? this.client._db.transaction(["AllFieldScalarTypes"], "readwrite"); + for (const createData of createManyData) { + const record = await this._fillDefaults(createData, tx); + await tx.objectStore("AllFieldScalarTypes").add(record); + } + return { count: createManyData.length }; + } +} diff --git a/packages/usage/src/prisma/schema.prisma b/packages/usage/src/prisma/schema.prisma index 4aa4509..f5a8c01 100644 --- a/packages/usage/src/prisma/schema.prisma +++ b/packages/usage/src/prisma/schema.prisma @@ -9,12 +9,39 @@ generator client { generator prismaIDB { provider = "prisma-idb-generator" - output = "../lib/prisma-idb" + output = "./prisma-idb" } -model Todo { - id String @id @default(uuid()) - task String - isCompleted Boolean - timeToComplete Int +model User { + id Int @id @default(autoincrement()) + name String + profile Profile? + posts Post[] +} + +model Profile { + id Int @id @default(autoincrement()) + bio String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int @unique +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + +model AllFieldScalarTypes { + id Int @id @default(autoincrement()) + string String + boolean Boolean + int Int + bigInt BigInt + float Float + decimal Decimal + dateTime DateTime + json Json + bytes Bytes } diff --git a/packages/usage/src/routes/+layout.svelte b/packages/usage/src/routes/+layout.svelte index 3fa208a..852a58c 100644 --- a/packages/usage/src/routes/+layout.svelte +++ b/packages/usage/src/routes/+layout.svelte @@ -1,6 +1,13 @@ -{@render children()} + + +
+

Prisma IDB test page

+ {@render children()} +
diff --git a/packages/usage/src/routes/+layout.ts b/packages/usage/src/routes/+layout.ts new file mode 100644 index 0000000..189f71e --- /dev/null +++ b/packages/usage/src/routes/+layout.ts @@ -0,0 +1 @@ +export const prerender = true; diff --git a/packages/usage/src/routes/+page.svelte b/packages/usage/src/routes/+page.svelte index c193cfd..b712526 100644 --- a/packages/usage/src/routes/+page.svelte +++ b/packages/usage/src/routes/+page.svelte @@ -1,122 +1,49 @@ -
-

Prisma-IDB usage page

-
-
- - - -
-

Completed Tasks: {totalCompletedTodos}

-

Total Time To Complete Task: {totalTimeToComplete}

-
-
- - - - Task Id - Task - Time To Complete - Status - Actions - - - - {#each allTodos as allTodo} - - {allTodo.id} - {allTodo.task} - {allTodo.timeToComplete} - - updateStatus(allTodo.id, event)} - /> - - - - - - {/each} - - -
- +
+ + + +
+ +
+ +
{output === "" ? "Run a query" : output}
diff --git a/packages/usage/svelte.config.js b/packages/usage/svelte.config.js index 4e7e5ff..1f012d2 100644 --- a/packages/usage/svelte.config.js +++ b/packages/usage/svelte.config.js @@ -1,4 +1,4 @@ -import adapter from "@sveltejs/adapter-auto"; +import adapter from "@sveltejs/adapter-static"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; /** @type {import('@sveltejs/kit').Config} */ diff --git a/packages/usage/tests/filterConditionsAndOperators/equals.spec.ts b/packages/usage/tests/filterConditionsAndOperators/equals.spec.ts new file mode 100644 index 0000000..d2cfa73 --- /dev/null +++ b/packages/usage/tests/filterConditionsAndOperators/equals.spec.ts @@ -0,0 +1,56 @@ +import { test } from "../fixtures"; +import { expectQueryToSucceed } from "../queryRunnerHelper"; + +test("equals_NullableStringField_ReturnsFilteredRecords", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "Alice" } }, + }); + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "John", profile: { create: { bio: "John's bio" } } } }, + }); + await expectQueryToSucceed({ + page, + model: "profile", + operation: "findMany", + query: { where: { bio: { equals: null } } }, + }); + await expectQueryToSucceed({ + page, + model: "profile", + operation: "findMany", + query: { where: { bio: { equals: "John's bio" } } }, + }); +}); + +test("equals_IntField_ReturnsFilteredRecords", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "Alice", id: 3 } }, + }); + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "John", id: 5 } }, + }); + await expectQueryToSucceed({ + page, + model: "profile", + operation: "findMany", + query: { where: { id: { equals: 3 } } }, + }); + await expectQueryToSucceed({ + page, + model: "profile", + operation: "findMany", + query: { where: { id: { equals: 1 } } }, + }); +}); diff --git a/packages/usage/tests/fixtures.ts b/packages/usage/tests/fixtures.ts new file mode 100644 index 0000000..9e38d98 --- /dev/null +++ b/packages/usage/tests/fixtures.ts @@ -0,0 +1,22 @@ +import { prisma } from "$lib/prisma"; +import { test as base } from "@playwright/test"; + +async function resetDatabase() { + await prisma.post.deleteMany(); + await prisma.user.deleteMany(); + await prisma.$executeRaw`ALTER SEQUENCE "User_id_seq" RESTART WITH 1`; + await prisma.$executeRaw`ALTER SEQUENCE "Profile_id_seq" RESTART WITH 1`; + await prisma.$executeRaw`ALTER SEQUENCE "Post_id_seq" RESTART WITH 1`; +} + +export const test = base.extend<{ prepareTest: void }>({ + prepareTest: [ + async ({ page }, use) => { + await resetDatabase(); + await page.goto("/"); + await use(); + }, + { auto: true }, + ], +}); +export { expect } from "@playwright/test"; diff --git a/packages/usage/tests/modelQueries/count.spec.ts b/packages/usage/tests/modelQueries/count.spec.ts new file mode 100644 index 0000000..b9c28b5 --- /dev/null +++ b/packages/usage/tests/modelQueries/count.spec.ts @@ -0,0 +1,24 @@ +import { test } from "../fixtures"; +import { expectQueryToSucceed } from "../queryRunnerHelper"; + +test("count_WithoutFilters_ReturnsTotalCount", async ({ page }) => { + await expectQueryToSucceed({ page, model: "user", operation: "count" }); + await expectQueryToSucceed({ page, model: "user", operation: "create", query: { data: { name: "John Doe" } } }); + await expectQueryToSucceed({ page, model: "user", operation: "count" }); +}); + +test("count_WithSelect_ReturnsSelectedFieldsOnly", async ({ page }) => { + await expectQueryToSucceed({ page, model: "user", operation: "create", query: { data: { name: "John Doe" } } }); + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "Alice", profile: { create: {} } } }, + }); + await expectQueryToSucceed({ + page, + model: "profile", + operation: "count", + query: { select: { _all: true, bio: true } }, + }); +}); diff --git a/packages/usage/tests/modelQueries/create.spec.ts b/packages/usage/tests/modelQueries/create.spec.ts new file mode 100644 index 0000000..991c687 --- /dev/null +++ b/packages/usage/tests/modelQueries/create.spec.ts @@ -0,0 +1,31 @@ +import { test } from "../fixtures"; +import { expectQueryToSucceed } from "../queryRunnerHelper"; + +test("create_ValidData_SuccessfullyCreatesRecord", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { id: 1, name: "John Doe" } }, + }); +}); + +test("create_WithGeneratedId_AssignsDefaultId", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "John Doe" } }, + }); +}); + +test("create_WithNullableField_AssignsNullAsDefault", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "profile", + operation: "create", + query: { data: { user: { create: { name: "John" } } } }, + }); +}); + +// TODO: all possible default functions diff --git a/packages/usage/tests/modelQueries/createMany.spec.ts b/packages/usage/tests/modelQueries/createMany.spec.ts new file mode 100644 index 0000000..231a788 --- /dev/null +++ b/packages/usage/tests/modelQueries/createMany.spec.ts @@ -0,0 +1,17 @@ +import { test } from "../fixtures"; +import { expectQueryToSucceed } from "../queryRunnerHelper"; + +test("createMany_ValidData_SuccessfullyCreatesRecords", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "createMany", + query: { data: [{ name: "John Doe" }, { name: "Alice" }] }, + }); + + await expectQueryToSucceed({ + page, + model: "user", + operation: "findMany", + }); +}); diff --git a/packages/usage/tests/modelQueries/findFirstOrThrow.spec.ts b/packages/usage/tests/modelQueries/findFirstOrThrow.spec.ts new file mode 100644 index 0000000..5714459 --- /dev/null +++ b/packages/usage/tests/modelQueries/findFirstOrThrow.spec.ts @@ -0,0 +1,11 @@ +import { test } from "../fixtures"; +import { expectQueryToFail, expectQueryToSucceed } from "../queryRunnerHelper"; + +test("findFirstOrThrow_NoMatchingRecords_ThrowsError", async ({ page }) => { + await expectQueryToFail({ page, model: "user", operation: "findFirstOrThrow", errorMessage: "Record not found" }); +}); + +test("findFirstOrThrow_ValidQuery_ReturnsFirstRecord", async ({ page }) => { + await expectQueryToSucceed({ page, model: "user", operation: "create", query: { data: { name: "John" } } }); + await expectQueryToSucceed({ page, model: "user", operation: "findFirstOrThrow" }); +}); diff --git a/packages/usage/tests/modelQueries/findUniqueOrThrow.spec.ts b/packages/usage/tests/modelQueries/findUniqueOrThrow.spec.ts new file mode 100644 index 0000000..12cf47c --- /dev/null +++ b/packages/usage/tests/modelQueries/findUniqueOrThrow.spec.ts @@ -0,0 +1,17 @@ +import { test } from "../fixtures"; +import { expectQueryToFail, expectQueryToSucceed } from "../queryRunnerHelper"; + +test("findUniqueOrThrow_NoMatchingRecords_ThrowsError", async ({ page }) => { + await expectQueryToFail({ + page, + model: "user", + operation: "findUniqueOrThrow", + errorMessage: "Record not found", + query: { where: { id: 1 } }, + }); +}); + +test("findUniqueOrThrow_ValidQuery_ReturnsFirstRecord", async ({ page }) => { + await expectQueryToSucceed({ page, model: "user", operation: "create", query: { data: { name: "John" } } }); + await expectQueryToSucceed({ page, model: "user", operation: "findUniqueOrThrow", query: { where: { id: 1 } } }); +}); diff --git a/packages/usage/tests/modelQueryOptions/include.spec.ts b/packages/usage/tests/modelQueryOptions/include.spec.ts new file mode 100644 index 0000000..3283d85 --- /dev/null +++ b/packages/usage/tests/modelQueryOptions/include.spec.ts @@ -0,0 +1,34 @@ +import { test } from "../fixtures"; +import { expectQueryToSucceed } from "../queryRunnerHelper"; + +test("include_WithOneToOneMetaOnOtherRelation_ReturnsRelatedData", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "John Doe", profile: { create: { bio: "John's Bio" } } } }, + }); + await expectQueryToSucceed({ page, model: "user", operation: "findMany", query: { include: { profile: true } } }); +}); + +test("include_WithOneToOneMetaOnCurrentRelation_ReturnsRelatedData", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "profile", + operation: "create", + query: { data: { bio: "John's Bio", user: { create: { name: "John Doe" } } } }, + }); + await expectQueryToSucceed({ page, model: "profile", operation: "findMany", query: { include: { user: true } } }); +}); + +test("include_WithOneToManyRelation_ReturnsRelatedData", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "John", posts: { create: [{ title: "post1" }, { title: "post2" }] } } }, + }); + await expectQueryToSucceed({ page, model: "user", operation: "findMany", query: { include: { posts: true } } }); +}); + +// TODO: test for other relation types (nested includes) diff --git a/packages/usage/tests/modelQueryOptions/select.spec.ts b/packages/usage/tests/modelQueryOptions/select.spec.ts new file mode 100644 index 0000000..3f5c606 --- /dev/null +++ b/packages/usage/tests/modelQueryOptions/select.spec.ts @@ -0,0 +1,34 @@ +import { test } from "../fixtures"; +import { expectQueryToSucceed } from "../queryRunnerHelper"; + +test("select_WithRelationAndName_ReturnsSelectedData", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "John Doe", profile: { create: { bio: "John's Bio" } } } }, + }); + await expectQueryToSucceed({ + page, + model: "user", + operation: "findMany", + query: { select: { profile: true, name: true } }, + }); +}); + +test("select_WithNestedRelationSelect_ReturnsSelectedData", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "John Doe", profile: { create: { bio: "John's Bio" } } } }, + }); + await expectQueryToSucceed({ + page, + model: "user", + operation: "findMany", + query: { select: { profile: { select: { bio: true } }, name: true } }, + }); +}); + +// TODO: test for other relation types (one-to-many, one-to-oneMetaOnCurrent) diff --git a/packages/usage/tests/nestedQueries/create.spec.ts b/packages/usage/tests/nestedQueries/create.spec.ts new file mode 100644 index 0000000..056d24f --- /dev/null +++ b/packages/usage/tests/nestedQueries/create.spec.ts @@ -0,0 +1,99 @@ +import { test } from "../fixtures"; +import { expectQueryToFail, expectQueryToSucceed } from "../queryRunnerHelper"; + +test("create_NestedCreateTransactionFailsOnError_RollsBackChanges", async ({ page }) => { + // Create test user with profile + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { id: 1, name: "Alice with bio", profile: { create: { bio: "generic bio" } } } }, + }); + + // Repeat with same IDs (should fail) + await expectQueryToFail({ + page, + model: "user", + operation: "create", + query: { data: { id: 1, name: "Alice with bio", profile: { create: { bio: "generic bio" } } } }, + errorMessage: + "Unable to add key to index 'userIdIndex': at least one key does not satisfy the uniqueness requirements.", + }); + + await expectQueryToSucceed({ page, model: "user", operation: "count" }); + await expectQueryToSucceed({ page, model: "profile", operation: "count" }); +}); + +test("create_WithOneToOneRelationMetaOnNested_SuccessfullyCreatesBothEntities", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "Alice with bio", profile: { create: { bio: "generic bio" } } } }, + }); + await expectQueryToSucceed({ + page, + model: "user", + operation: "findUnique", + query: { where: { id: 1 } }, + }); + await expectQueryToSucceed({ + page, + model: "profile", + operation: "findUnique", + query: { where: { userId: 1 } }, + }); +}); + +test("create_WithOneToOneRelationMetaOnParent_SuccessfullyCreatesBothEntities", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "profile", + operation: "create", + query: { data: { bio: "Alice's bio", user: { create: { name: "Alice" } } } }, + }); + await expectQueryToSucceed({ + page, + model: "user", + operation: "findUnique", + query: { where: { id: 1 } }, + }); +}); + +test("create_WithOneToManyRelation_CreatesParentAndOneChildRecord", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "Alice", posts: { create: { title: "Post1" } } } }, + }); + await expectQueryToSucceed({ + page, + model: "post", + operation: "findMany", + query: { where: { id: 1 } }, + }); +}); + +test("create_WithOneToManyRelation_CreatesParentAndManyChildRecords", async ({ page }) => { + await expectQueryToSucceed({ + page, + model: "user", + operation: "create", + query: { data: { name: "Alice", posts: { create: [{ title: "Post1" }, { title: "Post2" }] } } }, + }); + await expectQueryToSucceed({ + page, + model: "post", + operation: "findMany", + query: { where: { id: 1 } }, + }); +}); + +test("create_WithManyToManyRelation_CreatesJoinRecords", async () => { + // TODO +}); + +test("create_WithDeeplyNestedRelations_PersistsAllEntities", async () => { + // TODO +}); diff --git a/packages/usage/tests/queryRunnerHelper.ts b/packages/usage/tests/queryRunnerHelper.ts new file mode 100644 index 0000000..5ac3c0f --- /dev/null +++ b/packages/usage/tests/queryRunnerHelper.ts @@ -0,0 +1,35 @@ +import { prisma } from "$lib/prisma"; +import { expect, type Page } from "@playwright/test"; +import type { Prisma } from "@prisma/client"; +import type { Operation } from "@prisma/client/runtime/library"; + +export async function expectQueryToSucceed< + M extends Exclude, + F extends Exclude, +>(params: { page: Page; model: M; operation: F; query?: Prisma.Args<(typeof prisma)[M], F> }) { + const { page, model, operation, query } = params; + + const operationFunction = prisma[model][operation] as (...args: unknown[]) => unknown; + const prismaClientResult = await operationFunction(query); + + await page.getByTestId("query-input").fill(`${model}.${operation}(${JSON.stringify(query)})`); + await page.getByRole("button", { name: "Run query" }).click(); + await expect(page.getByRole("status").last()).toContainText("Query executed successfully"); + + const idbClientResult = (await page.getByRole("code").textContent()) ?? ""; + expect(JSON.parse(idbClientResult)).toEqual(prismaClientResult); +} + +export async function expectQueryToFail< + M extends Exclude, + F extends Exclude, +>(params: { page: Page; model: M; operation: F; query?: Prisma.Args<(typeof prisma)[M], F>; errorMessage: string }) { + const { page, model, operation, query, errorMessage } = params; + + const operationFunction = prisma[model][operation] as (...args: unknown[]) => unknown; + await expect(operationFunction).rejects.toThrowError(); + + await page.getByTestId("query-input").fill(`${model}.${operation}(${JSON.stringify(query)})`); + await page.getByRole("button", { name: "Run query" }).click(); + await expect(page.getByRole("status").filter({ hasText: errorMessage })).toBeVisible(); +} diff --git a/packages/usage/tests/test-crud.spec.ts b/packages/usage/tests/test-crud.spec.ts deleted file mode 100644 index 628c381..0000000 --- a/packages/usage/tests/test-crud.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test("should add todo", async ({ page }) => { - await page.goto("/"); - await page.getByPlaceholder("Enter Task").click(); - await page.getByPlaceholder("Enter Task").fill("finish assignments"); - await page.getByRole("button", { name: "Add Task" }).click(); - await expect(page.getByRole("cell", { name: "finish assignments" })).toBeVisible(); -}); - -test("should read added todo", async ({ page }) => { - await page.goto("/"); - await page.getByPlaceholder("Enter Task").click(); - await page.getByPlaceholder("Enter Task").fill("test read"); - await page.getByRole("button", { name: "Add Task" }).click(); - await page.getByRole("cell", { name: "test read" }).click(); - await expect(page.locator("tbody")).toContainText("test read"); - await page.locator("td").nth(2).click(); -}); - -test("should update added todo", async ({ page }) => { - await page.goto("/"); - await page.getByPlaceholder("Enter Task").click(); - await page.getByPlaceholder("Enter Task").fill("test update"); - await page.getByPlaceholder("Enter Task").press("Enter"); - await page.getByRole("button", { name: "Add Task" }).click(); - await expect(page.locator("tbody")).toContainText("test update"); - await page.getByRole("checkbox").check(); - await page.getByRole("checkbox").uncheck(); - await page.getByRole("checkbox").check(); - await expect(page.getByRole("checkbox")).toBeVisible(); -}); - -test("should show total todos", async ({ page }) => { - await page.goto("/"); - await page.getByPlaceholder("Enter Task").click(); - await page.getByPlaceholder("Enter Task").fill("1"); - await page.getByRole("button", { name: "Add Task" }).click(); - await page.getByRole("checkbox").check(); - await expect(page.locator("body")).toContainText("Completed Tasks: 1"); - await page.getByRole("checkbox").uncheck(); - await expect(page.locator("body")).toContainText("Completed Tasks: 0"); -});