diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 476dd162..00000000 --- a/.editorconfig +++ /dev/null @@ -1,20 +0,0 @@ -# EditorConfig coding styles definitions. For more information about the -# properties used in this file, please see the EditorConfig documentation: -# http://editorconfig.org/ - -root = true - -[*] -charset = utf-8 -end_of_line = LF -indent_size = 4 -indent_style = tab -insert_final_newline = true -trim_trailing_whitespace = true - -[*.{yml,json}] -indent_size = 2 -indent_style = space - -[*.{md,diff}] -trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 082a8e70..e0d53739 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,59 +1,65 @@ name: CI on: - push: - branches: - - main - pull_request: + push: + branches: + - main + pull_request: jobs: - lint: - name: Linting - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Setup Deno - uses: denoland/setup-deno@v1 - with: - deno-version: v1.x - - - name: Check formatting - run: deno fmt --check - - name: Run linting - run: deno lint - - test: - name: Testing - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Setup deno - uses: denoland/setup-deno@v1 - with: - deno-version: v1.x - - - name: Setup SurrealDB - run: curl -sSf https://install.surrealdb.com | sh - - - name: Run Test - run: deno task test - - build: - name: Build library - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Setup deno - uses: denoland/setup-deno@v1 - with: - deno-version: v1.x - - name: Setup node - uses: actions/setup-node@v4 - with: - node-version: 20.x - - - name: Run Build - run: deno task build + quality: + name: Code Quality + runs-on: ubuntu-latest + steps: + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Code Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: bun install + + - name: Checking Code Quality + run: bun run quality:check + + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + surrealdb: ["v1.4.2", "v1.5.3", "v2.0.0-alpha.4"] + engine: ["ws", "http"] + steps: + - name: Install SurrealDB ${{ matrix.surrealdb }} over ${{ matrix.engine }} engine + run: curl --proto '=https' --tlsv1.2 -sSf https://install.surrealdb.com | sh -s -- --version ${{ matrix.surrealdb }} + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Code Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: bun install + + - name: Run tests + run: bun test + env: + SURREAL_PROTOCOL: ${{ matrix.engine }} + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Code Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: bun install + + - name: Build library + run: bun run build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0c0fafc0..fb86d507 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,21 +1,51 @@ -name: Publish Package to npmjs +name: Publish + on: push: tags: - '*' + jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: denoland/setup-deno@v1 - with: - deno-version: v1.x - - uses: actions/setup-node@v3 - with: - node-version: '18.x' - registry-url: 'https://registry.npmjs.org' - - uses: actions/checkout@v3 - - run: deno task build - - run: cd ./npm && npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + npm: + name: NPM + runs-on: ubuntu-latest + steps: + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - uses: actions/setup-node@v4 + with: + node-version: '18.x' + registry-url: 'https://registry.npmjs.org' + + - name: Code Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: bun install + + - name: Build library + run: bun run build + + - name: Publish to NPM + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + jsr: + name: JSR + runs-on: ubuntu-latest + steps: + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Code Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: bun install + + - name: Generate JSR config + run: bun run jsr + + - name: Publish to JSR + run: bunx jsr publish diff --git a/.gitignore b/.gitignore index ce4e6346..0c3d6080 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ Temporary Items /.test_data npm node_modules +dist # ----------------------------------- @@ -35,3 +36,4 @@ node_modules # ----------------------------------- *.db +jsr.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 09cf720d..98330fa7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,4 @@ { - "recommendations": [ - "denoland.vscode-deno" - ] + "recommendations": ["biomejs.biome", "oven.bun-vscode"], + "unwantedRecommendations": [] } diff --git a/.vscode/settings.json b/.vscode/settings.json index cbac5697..71c603b2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "deno.enable": true + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true } diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 00000000..1c62965b --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,13 @@ +{ + "formatter": { + "external": { + "command": "bunx", + "arguments": [ + "biome", + "format", + "--write", + "--stdin-file-path={buffer_path}" + ] + } + } +} diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..74d991bd --- /dev/null +++ b/biome.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "files": { + "ignore": ["dist/**"] + } +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 00000000..5596707c Binary files /dev/null and b/bun.lockb differ diff --git a/compile.ts b/compile.ts deleted file mode 100644 index 4235b89b..00000000 --- a/compile.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { build, emptyDir } from "https://deno.land/x/dnt@0.34.0/mod.ts"; -import project from "./project.json" with { type: "json" }; - -await emptyDir("./npm"); - -await build({ - entryPoints: ["./src/index.ts"], - outDir: "./npm", - shims: { - // see JS docs for overview and more options - deno: false, - webSocket: false, - }, - package: { - // package.json properties - name: "surrealdb.js", - version: project.version, - description: "Javascript driver for SurrealDB", - license: "Apache 2.0", - repository: { - type: "git", - url: "https://github.com/surrealdb/surrealdb.js.git", - }, - author: { - name: "Tobie Morgan Hitchcock", - url: "https://surrealdb.com", - }, - dependencies: { - "isows": "^1.0.4", - "ws": "^8.16.0", - "semver": "^7.5.4", - }, - optionalDependencies: { - "bufferutil": "^4.0.8", - "utf-8-validate": "^6.0.3", - }, - devDependencies: { - "@types/node": "^18.7.18", - "@types/ws": "8.5.3", - "esbuild": "0.15.8", - "@types/semver": "^7.5.8", - }, - scripts: { - "build:web": - "esbuild ./esm/index.js --format=esm --minify --bundle --sourcemap --outfile=./web/index.js", - }, - browser: "./web/index.js", - engines: { - node: ">=18.0.0", - }, - }, - // skipSourceOutput: true, - mappings: { - "./src/library/WebSocket/deno.ts": "./src/library/WebSocket/node.ts", - }, - compilerOptions: { - lib: ["dom", "es2021"], - sourceMap: true, - }, -}); - -// post build steps -Deno.copyFileSync("LICENSE", "npm/LICENSE"); -Deno.copyFileSync("README.md", "npm/README.md"); -Deno.copyFileSync(".npmrc", "npm/.npmrc"); diff --git a/deno.json b/deno.json deleted file mode 100644 index 713e5819..00000000 --- a/deno.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "lint": { - "include": ["src/", "tests/", "./mod.ts", "./compile.ts", "./deno.json"] - }, - "fmt": { - "include": [ - "src/", - "tests/", - "./mod.ts", - "./compile.ts", - "./deno.json" - ], - "useTabs": true, - "lineWidth": 80, - "indentWidth": 4, - "singleQuote": false - }, - "tasks": { - "build": "deno run -A ./compile.ts && cd npm && npm run build:web", - "test": "deno test --allow-net --allow-run --allow-read --trace-leaks", - "test:update": "deno test --allow-net --allow-run --allow-read --allow-write --trace-leaks -- --update" - }, - "test": { - "include": [ - "tests/unit/*.ts", - "tests/integration/ws.ts", - "tests/integration/http.ts" - ] - } -} diff --git a/deno.lock b/deno.lock deleted file mode 100644 index a5eb202e..00000000 --- a/deno.lock +++ /dev/null @@ -1,304 +0,0 @@ -{ - "version": "3", - "packages": { - "specifiers": { - "npm:@icholy/duration@^5.1.0": "npm:@icholy/duration@5.1.0", - "npm:@types/node@^20.11.16": "npm:@types/node@20.12.2", - "npm:cbor-redux@1.0.0": "npm:cbor-redux@1.0.0", - "npm:cbor-redux@^1.0.0": "npm:cbor-redux@1.0.0", - "npm:decimal.js@10.4.3": "npm:decimal.js@10.4.3", - "npm:decimal.js@^10.4.3": "npm:decimal.js@10.4.3", - "npm:isows@^1.0.4": "npm:isows@1.0.4_ws@8.16.0", - "npm:node-fetch@^3.3.1": "npm:node-fetch@3.3.2", - "npm:semver": "npm:semver@7.5.4", - "npm:typescript@^5.3.3": "npm:typescript@5.4.3", - "npm:uuidv7@0.6.3": "npm:uuidv7@0.6.3", - "npm:uuidv7@^0.6.3": "npm:uuidv7@0.6.3", - "npm:zod": "npm:zod@3.22.4", - "npm:zod@^3.22.4": "npm:zod@3.22.4" - }, - "npm": { - "@deno/shim-deno-test@0.4.0": { - "integrity": "sha512-oYWcD7CpERZy/TXMTM9Tgh1HD/POHlbY9WpzmAk+5H8DohcxG415Qws8yLGlim3EaKBT2v3lJv01x4G0BosnaQ==", - "dependencies": {} - }, - "@deno/shim-deno@0.16.1": { - "integrity": "sha512-s9v0kzF5bm/o9TgdwvsraHx6QNllYrXXmKzgOG2lh4LFXnVMr2gpjK/c/ve6EflQn1MqImcWmVD8HAv5ahuuZQ==", - "dependencies": { - "@deno/shim-deno-test": "@deno/shim-deno-test@0.4.0", - "which": "which@2.0.2" - } - }, - "@icholy/duration@5.1.0": { - "integrity": "sha512-I/zdjC6qYdwWJ2H1/PZbI3g58pPIiI/eOe5XDTtQ/v36d0ogcvAylqwOIWj/teY1rBnIMzUyWfX7PMm9I67WWg==", - "dependencies": {} - }, - "@types/node@20.12.2": { - "integrity": "sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==", - "dependencies": { - "undici-types": "undici-types@5.26.5" - } - }, - "cbor-redux@1.0.0": { - "integrity": "sha512-nqCD/Yu2FON0XgZYdUNsMx1Tc08MOY3noh9bO2MvkjlyLZyqIVWjuz6A0mDrPJncdOfacokFUtF7zlzzP5oK5A==", - "dependencies": { - "@deno/shim-deno": "@deno/shim-deno@0.16.1" - } - }, - "data-uri-to-buffer@4.0.1": { - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dependencies": {} - }, - "decimal.js@10.4.3": { - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", - "dependencies": {} - }, - "fetch-blob@3.2.0": { - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dependencies": { - "node-domexception": "node-domexception@1.0.0", - "web-streams-polyfill": "web-streams-polyfill@3.3.3" - } - }, - "formdata-polyfill@4.0.10": { - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dependencies": { - "fetch-blob": "fetch-blob@3.2.0" - } - }, - "isexe@2.0.0": { - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dependencies": {} - }, - "isows@1.0.4_ws@8.16.0": { - "integrity": "sha512-hEzjY+x9u9hPmBom9IIAqdJCwNLax+xrPb51vEPpERoFlIxgmZcHzsT5jKG06nvInKOBGvReAVz80Umed5CczQ==", - "dependencies": { - "ws": "ws@8.16.0" - } - }, - "lru-cache@6.0.0": { - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "yallist@4.0.0" - } - }, - "node-domexception@1.0.0": { - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "dependencies": {} - }, - "node-fetch@3.3.2": { - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dependencies": { - "data-uri-to-buffer": "data-uri-to-buffer@4.0.1", - "fetch-blob": "fetch-blob@3.2.0", - "formdata-polyfill": "formdata-polyfill@4.0.10" - } - }, - "semver@7.5.4": { - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "lru-cache@6.0.0" - } - }, - "typescript@5.4.3": { - "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", - "dependencies": {} - }, - "undici-types@5.26.5": { - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dependencies": {} - }, - "uuidv7@0.6.3": { - "integrity": "sha512-zV3eW2NlXTsun/aJ7AixxZjH/byQcH/r3J99MI0dDEkU2cJIBJxhEWUHDTpOaLPRNhebPZoeHuykYREkI9HafA==", - "dependencies": {} - }, - "web-streams-polyfill@3.3.3": { - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dependencies": {} - }, - "which@2.0.2": { - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "isexe@2.0.0" - } - }, - "ws@8.16.0": { - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", - "dependencies": {} - }, - "yallist@4.0.0": { - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dependencies": {} - }, - "zod@3.22.4": { - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", - "dependencies": {} - } - } - }, - "redirects": { - "https://deno.land/x/port/mod.ts": "https://deno.land/x/port@1.0.0/mod.ts" - }, - "remote": { - "https://deno.land/std@0.140.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", - "https://deno.land/std@0.140.0/_util/os.ts": "3b4c6e27febd119d36a416d7a97bd3b0251b77c88942c8f16ee5953ea13e2e49", - "https://deno.land/std@0.140.0/bytes/bytes_list.ts": "67eb118e0b7891d2f389dad4add35856f4ad5faab46318ff99653456c23b025d", - "https://deno.land/std@0.140.0/bytes/equals.ts": "fc16dff2090cced02497f16483de123dfa91e591029f985029193dfaa9d894c9", - "https://deno.land/std@0.140.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf", - "https://deno.land/std@0.140.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37", - "https://deno.land/std@0.140.0/fs/_util.ts": "0fb24eb4bfebc2c194fb1afdb42b9c3dda12e368f43e8f2321f84fc77d42cb0f", - "https://deno.land/std@0.140.0/fs/ensure_dir.ts": "9dc109c27df4098b9fc12d949612ae5c9c7169507660dcf9ad90631833209d9d", - "https://deno.land/std@0.140.0/hash/sha256.ts": "803846c7a5a8a5a97f31defeb37d72f519086c880837129934f5d6f72102a8e8", - "https://deno.land/std@0.140.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b", - "https://deno.land/std@0.140.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", - "https://deno.land/std@0.140.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", - "https://deno.land/std@0.140.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b", - "https://deno.land/std@0.140.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", - "https://deno.land/std@0.140.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", - "https://deno.land/std@0.140.0/path/mod.ts": "d3e68d0abb393fb0bf94a6d07c46ec31dc755b544b13144dee931d8d5f06a52d", - "https://deno.land/std@0.140.0/path/posix.ts": "293cdaec3ecccec0a9cc2b534302dfe308adb6f10861fa183275d6695faace44", - "https://deno.land/std@0.140.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", - "https://deno.land/std@0.140.0/path/win32.ts": "31811536855e19ba37a999cd8d1b62078235548d67902ece4aa6b814596dd757", - "https://deno.land/std@0.140.0/streams/conversion.ts": "712585bfa0172a97fb68dd46e784ae8ad59d11b88079d6a4ab098ff42e697d21", - "https://deno.land/std@0.181.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", - "https://deno.land/std@0.181.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", - "https://deno.land/std@0.181.0/fs/_util.ts": "65381f341af1ff7f40198cee15c20f59951ac26e51ddc651c5293e24f9ce6f32", - "https://deno.land/std@0.181.0/fs/ensure_dir.ts": "dc64c4c75c64721d4e3fb681f1382f803ff3d2868f08563ff923fdd20d071c40", - "https://deno.land/std@0.181.0/fs/expand_glob.ts": "e4f56259a0a70fe23f05215b00de3ac5e6ba46646ab2a06ebbe9b010f81c972a", - "https://deno.land/std@0.181.0/fs/walk.ts": "ea95ffa6500c1eda6b365be488c056edc7c883a1db41ef46ec3bf057b1c0fe32", - "https://deno.land/std@0.181.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.181.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.181.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", - "https://deno.land/std@0.181.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", - "https://deno.land/std@0.181.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", - "https://deno.land/std@0.181.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", - "https://deno.land/std@0.181.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", - "https://deno.land/std@0.181.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", - "https://deno.land/std@0.181.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", - "https://deno.land/std@0.182.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", - "https://deno.land/std@0.182.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", - "https://deno.land/std@0.182.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", - "https://deno.land/std@0.182.0/fs/_util.ts": "65381f341af1ff7f40198cee15c20f59951ac26e51ddc651c5293e24f9ce6f32", - "https://deno.land/std@0.182.0/fs/empty_dir.ts": "c3d2da4c7352fab1cf144a1ecfef58090769e8af633678e0f3fabaef98594688", - "https://deno.land/std@0.182.0/fs/expand_glob.ts": "e4f56259a0a70fe23f05215b00de3ac5e6ba46646ab2a06ebbe9b010f81c972a", - "https://deno.land/std@0.182.0/fs/walk.ts": "920be35a7376db6c0b5b1caf1486fb962925e38c9825f90367f8f26b5e5d0897", - "https://deno.land/std@0.182.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.182.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.182.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", - "https://deno.land/std@0.182.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", - "https://deno.land/std@0.182.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", - "https://deno.land/std@0.182.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", - "https://deno.land/std@0.182.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", - "https://deno.land/std@0.182.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", - "https://deno.land/std@0.182.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", - "https://deno.land/std@0.223.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", - "https://deno.land/std@0.223.0/assert/_diff.ts": "4bf42969aa8b1a33aaf23eb8e478b011bfaa31b82d85d2ff4b5c4662d8780d2b", - "https://deno.land/std@0.223.0/assert/_format.ts": "0ba808961bf678437fb486b56405b6fefad2cf87b5809667c781ddee8c32aff4", - "https://deno.land/std@0.223.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", - "https://deno.land/std@0.223.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", - "https://deno.land/std@0.223.0/assert/assert_array_includes.ts": "167b2c29997defd49a1835de52b54ae3cbb2bcba52df7c7ee45fe64b473264f1", - "https://deno.land/std@0.223.0/assert/assert_equals.ts": "cc1f4b0ff4ad511e69f965535b56a6cdbbbc0f086bf376e0243214df6039c883", - "https://deno.land/std@0.223.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", - "https://deno.land/std@0.223.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", - "https://deno.land/std@0.223.0/assert/assert_greater.ts": "26903fc7170a9eb37ee6c6606c772b5a0465a85e719cfe46f57de35555931419", - "https://deno.land/std@0.223.0/assert/assert_greater_or_equal.ts": "10527cf379a71a55a88b96d9b3373d0346ea2bdd8d73d2faaab1224e2cedb727", - "https://deno.land/std@0.223.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", - "https://deno.land/std@0.223.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", - "https://deno.land/std@0.223.0/assert/assert_less.ts": "091f0cc80f53425be22b14c9b6ae410fea08e16eca391a476303c5f4852fcd9e", - "https://deno.land/std@0.223.0/assert/assert_less_or_equal.ts": "9418b2f809023f778d58fd6834410814900eacb8a91708647970ae1085553813", - "https://deno.land/std@0.223.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", - "https://deno.land/std@0.223.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", - "https://deno.land/std@0.223.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", - "https://deno.land/std@0.223.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", - "https://deno.land/std@0.223.0/assert/assert_not_strict_equals.ts": "61e4adfd80eddaab5da5e5444431dfb19457f26b1f1e7a8be27c5d981b7f50b9", - "https://deno.land/std@0.223.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", - "https://deno.land/std@0.223.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", - "https://deno.land/std@0.223.0/assert/assert_strict_equals.ts": "dbcdcb5b8b74e6c06bce6a9fa43ff4d1089793e7832baff251e514954b9b266b", - "https://deno.land/std@0.223.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", - "https://deno.land/std@0.223.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", - "https://deno.land/std@0.223.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", - "https://deno.land/std@0.223.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", - "https://deno.land/std@0.223.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", - "https://deno.land/std@0.223.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", - "https://deno.land/std@0.223.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", - "https://deno.land/std@0.223.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", - "https://deno.land/std@0.223.0/fmt/colors.ts": "d239d84620b921ea520125d778947881f62c50e78deef2657073840b8af9559a", - "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", - "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", - "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", - "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", - "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", - "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", - "https://deno.land/std@0.224.0/fs/_get_file_info_type.ts": "da7bec18a7661dba360a1db475b826b18977582ce6fc9b25f3d4ee0403fe8cbd", - "https://deno.land/std@0.224.0/fs/_to_path_string.ts": "29bfc9c6c112254961d75cbf6ba814d6de5349767818eb93090cecfa9665591e", - "https://deno.land/std@0.224.0/fs/ensure_dir.ts": "51a6279016c65d2985f8803c848e2888e206d1b510686a509fa7cc34ce59d29f", - "https://deno.land/std@0.224.0/fs/ensure_file.ts": "67608cf550529f3d4aa1f8b6b36bf817bdc40b14487bf8f60e61cbf68f507cf3", - "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", - "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", - "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", - "https://deno.land/std@0.224.0/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8", - "https://deno.land/std@0.224.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", - "https://deno.land/std@0.224.0/path/_common/dirname.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", - "https://deno.land/std@0.224.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf", - "https://deno.land/std@0.224.0/path/_common/normalize_string.ts": "33edef773c2a8e242761f731adeb2bd6d683e9c69e4e3d0092985bede74f4ac3", - "https://deno.land/std@0.224.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", - "https://deno.land/std@0.224.0/path/_common/to_file_url.ts": "7f76adbc83ece1bba173e6e98a27c647712cab773d3f8cbe0398b74afc817883", - "https://deno.land/std@0.224.0/path/_interface.ts": "8dfeb930ca4a772c458a8c7bbe1e33216fe91c253411338ad80c5b6fa93ddba0", - "https://deno.land/std@0.224.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", - "https://deno.land/std@0.224.0/path/dirname.ts": "85bd955bf31d62c9aafdd7ff561c4b5fb587d11a9a5a45e2b01aedffa4238a7c", - "https://deno.land/std@0.224.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069", - "https://deno.land/std@0.224.0/path/parse.ts": "77ad91dcb235a66c6f504df83087ce2a5471e67d79c402014f6e847389108d5a", - "https://deno.land/std@0.224.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", - "https://deno.land/std@0.224.0/path/posix/dirname.ts": "76cd348ffe92345711409f88d4d8561d8645353ac215c8e9c80140069bf42f00", - "https://deno.land/std@0.224.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40", - "https://deno.land/std@0.224.0/path/posix/is_absolute.ts": "cebe561ad0ae294f0ce0365a1879dcfca8abd872821519b4fcc8d8967f888ede", - "https://deno.land/std@0.224.0/path/posix/parse.ts": "09dfad0cae530f93627202f28c1befa78ea6e751f92f478ca2cc3b56be2cbb6a", - "https://deno.land/std@0.224.0/path/posix/resolve.ts": "08b699cfeee10cb6857ccab38fa4b2ec703b0ea33e8e69964f29d02a2d5257cf", - "https://deno.land/std@0.224.0/path/posix/to_file_url.ts": "7aa752ba66a35049e0e4a4be5a0a31ac6b645257d2e031142abb1854de250aaf", - "https://deno.land/std@0.224.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d", - "https://deno.land/std@0.224.0/path/to_file_url.ts": "88f049b769bce411e2d2db5bd9e6fd9a185a5fbd6b9f5ad8f52bef517c4ece1b", - "https://deno.land/std@0.224.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", - "https://deno.land/std@0.224.0/path/windows/dirname.ts": "33e421be5a5558a1346a48e74c330b8e560be7424ed7684ea03c12c21b627bc9", - "https://deno.land/std@0.224.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01", - "https://deno.land/std@0.224.0/path/windows/is_absolute.ts": "4a8f6853f8598cf91a835f41abed42112cebab09478b072e4beb00ec81f8ca8a", - "https://deno.land/std@0.224.0/path/windows/parse.ts": "08804327b0484d18ab4d6781742bf374976de662f8642e62a67e93346e759707", - "https://deno.land/std@0.224.0/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972", - "https://deno.land/std@0.224.0/path/windows/to_file_url.ts": "40e560ee4854fe5a3d4d12976cef2f4e8914125c81b11f1108e127934ced502e", - "https://deno.land/std@0.224.0/testing/snapshot.ts": "35ca1c8e8bfb98d7b7e794f1b7be8d992483fcff572540e41396f22a5bddb944", - "https://deno.land/x/code_block_writer@12.0.0/mod.ts": "2c3448060e47c9d08604c8f40dee34343f553f33edcdfebbf648442be33205e5", - "https://deno.land/x/code_block_writer@12.0.0/utils/string_utils.ts": "60cb4ec8bd335bf241ef785ccec51e809d576ff8e8d29da43d2273b69ce2a6ff", - "https://deno.land/x/deno_cache@0.4.1/auth_tokens.ts": "5fee7e9155e78cedf3f6ff3efacffdb76ac1a76c86978658d9066d4fb0f7326e", - "https://deno.land/x/deno_cache@0.4.1/cache.ts": "51f72f4299411193d780faac8c09d4e8cbee951f541121ef75fcc0e94e64c195", - "https://deno.land/x/deno_cache@0.4.1/deno_dir.ts": "f2a9044ce8c7fe1109004cda6be96bf98b08f478ce77e7a07f866eff1bdd933f", - "https://deno.land/x/deno_cache@0.4.1/deps.ts": "8974097d6c17e65d9a82d39377ae8af7d94d74c25c0cbb5855d2920e063f2343", - "https://deno.land/x/deno_cache@0.4.1/dirs.ts": "d2fa473ef490a74f2dcb5abb4b9ab92a48d2b5b6320875df2dee64851fa64aa9", - "https://deno.land/x/deno_cache@0.4.1/disk_cache.ts": "1f3f5232cba4c56412d93bdb324c624e95d5dd179d0578d2121e3ccdf55539f9", - "https://deno.land/x/deno_cache@0.4.1/file_fetcher.ts": "07a6c5f8fd94bf50a116278cc6012b4921c70d2251d98ce1c9f3c352135c39f7", - "https://deno.land/x/deno_cache@0.4.1/http_cache.ts": "f632e0d6ec4a5d61ae3987737a72caf5fcdb93670d21032ddb78df41131360cd", - "https://deno.land/x/deno_cache@0.4.1/mod.ts": "ef1cda9235a93b89cb175fe648372fc0f785add2a43aa29126567a05e3e36195", - "https://deno.land/x/deno_cache@0.4.1/util.ts": "8cb686526f4be5205b92c819ca2ce82220aa0a8dd3613ef0913f6dc269dbbcfe", - "https://deno.land/x/dnt@0.34.0/lib/compiler.ts": "dd589db479d6d7e69999865003ab83c41544e251ece4f21f2f2ee74557097ba6", - "https://deno.land/x/dnt@0.34.0/lib/compiler_transforms.ts": "cbb1fd5948f5ced1aa5c5aed9e45134e2357ce1e7220924c1d7bded30dcd0dd0", - "https://deno.land/x/dnt@0.34.0/lib/mod.deps.ts": "30367fc68bcd2acf3b7020cf5cdd26f817f7ac9ac35c4bfb6c4551475f91bc3e", - "https://deno.land/x/dnt@0.34.0/lib/npm_ignore.ts": "ddc1a7a76b288ca471bf1a6298527887a0f9eb7e25008072fd9c9fa9bb28c71a", - "https://deno.land/x/dnt@0.34.0/lib/package_json.ts": "2d629dbaef8004971e38ce3661f04b915a35342b905c3d98ff4a25343c2a8293", - "https://deno.land/x/dnt@0.34.0/lib/pkg/dnt_wasm.generated.js": "ad5c205f018b2bc6258d00d6a0539c2ffa94275f16f106f0f072bcf77f3c786b", - "https://deno.land/x/dnt@0.34.0/lib/pkg/snippets/dnt-wasm-a15ef721fa5290c5/helpers.js": "a6b95adc943a68d513fe8ed9ec7d260ac466b7a4bced4e942f733e494bb9f1be", - "https://deno.land/x/dnt@0.34.0/lib/shims.ts": "f6030d8dab258dd2d12bad93353e11143ee7fba8718d24d13d71f40b10c5df47", - "https://deno.land/x/dnt@0.34.0/lib/test_runner/get_test_runner_code.ts": "2a4e26aa33120f3cc9e03b8538211a5047a4bad4c64e895944b87f2dcd55d904", - "https://deno.land/x/dnt@0.34.0/lib/test_runner/test_runner.ts": "b91d77d9d4b82984cb2ba7431ba6935756ba72f62e7dd4db22cd47a680ebd952", - "https://deno.land/x/dnt@0.34.0/lib/transform.deps.ts": "e42f2bdef46d098453bdba19261a67cf90b583f5d868f7fe83113c1380d9b85c", - "https://deno.land/x/dnt@0.34.0/lib/types.ts": "34e45a3136c2f21f797173ea46d9ea5d1639eb7b834a5bd565aad4214fa32603", - "https://deno.land/x/dnt@0.34.0/lib/utils.ts": "d13b5b3148a2c71e9b2f1c84c7be7393b825ae972505e23c2f6b1e5287e96b43", - "https://deno.land/x/dnt@0.34.0/mod.ts": "3ee53f4d4d41df72e57ecbca9f3c2b7cf86166ef57fa04452865780f83c555a9", - "https://deno.land/x/dnt@0.34.0/transform.ts": "1b127c5f22699c8ab2545b98aeca38c4e5c21405b0f5342ea17e9c46280ed277", - "https://deno.land/x/port@1.0.0/mod.ts": "2dc04ce1ccf133ae09205e30b550044c4c6f64a1a7d00ea91c66dbb9f6cc00f5", - "https://deno.land/x/port@1.0.0/types.ts": "42d6ae4147d5d67408d60209da070ddfa79ec8389c6cab1b8002df0cf6c03af6", - "https://deno.land/x/ts_morph@18.0.0/bootstrap/mod.ts": "b53aad517f106c4079971fcd4a81ab79fadc40b50061a3ab2b741a09119d51e9", - "https://deno.land/x/ts_morph@18.0.0/bootstrap/ts_morph_bootstrap.js": "6645ac03c5e6687dfa8c78109dc5df0250b811ecb3aea2d97c504c35e8401c06", - "https://deno.land/x/ts_morph@18.0.0/common/DenoRuntime.ts": "6a7180f0c6e90dcf23ccffc86aa8271c20b1c4f34c570588d08a45880b7e172d", - "https://deno.land/x/ts_morph@18.0.0/common/mod.ts": "01985d2ee7da8d1caee318a9d07664774fbee4e31602bc2bb6bb62c3489555ed", - "https://deno.land/x/ts_morph@18.0.0/common/ts_morph_common.js": "845671ca951073400ce142f8acefa2d39ea9a51e29ca80928642f3f8cf2b7700", - "https://deno.land/x/ts_morph@18.0.0/common/typescript.js": "d5c598b6a2db2202d0428fca5fd79fc9a301a71880831a805d778797d2413c59" - } -} diff --git a/mod.ts b/mod.ts deleted file mode 100644 index 6f3a0d0f..00000000 --- a/mod.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./src/index.ts"; -export { default } from "./src/index.ts"; diff --git a/package.json b/package.json new file mode 100644 index 00000000..e4f0c370 --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "surrealdb.js", + "version": "1.0.0-beta.10", + "type": "module", + "license": "Apache-2.0", + "repository": { + "url": "https://github.com/surrealdb/surrealdb.js" + }, + "homepage": "https://github.com/surrealdb/surrealdb.js", + "packageManager": "^bun@1.1.17", + "devDependencies": { + "@biomejs/biome": "1.8.3", + "@types/bun": "latest", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.21.5", + "esbuild-plugin-tsc": "^0.4.0", + "get-port": "^7.1.0" + }, + "peerDependencies": { + "typescript": "^5.0.0", + "tslib": "^2.6.3" + }, + "dependencies": { + "isows": "^1.0.4", + "uuidv7": "^1.0.1" + }, + "scripts": { + "ts": "tsc --watch --noEmit true --emitDeclarationOnly false", + "quality:check": "biome check .", + "quality:apply": "biome check . --write", + "quality:apply:unsafe": "biome check . --write --unsafe", + "build": "bun run scripts/build.ts", + "jsr": "bun run scripts/jsr.ts" + }, + "optionalDependencies": { + "bufferutil": "^4.0.8", + "utf-8-validate": "^6.0.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "browser": "./dist/esm.bundled.js", + "types": "./dist/types.d.ts", + "main": "./dist/esm.js", + "exports": { + ".": { + "require": "./dist/cjs.js", + "import": "./dist/esm.js", + "types": "./dist/types.d.ts", + "browser": "./dist/esm.bundled.js" + } + }, + "files": ["dist", "README.md", "LICENCE", "SECURITY.md"] +} diff --git a/project.json b/project.json deleted file mode 100644 index 1dfe63f2..00000000 --- a/project.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "surrealdb.js", - "version": "1.0.0-beta.9" -} diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 00000000..910de6ac --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,42 @@ +import * as esbuild from "esbuild"; +import tscPlugin from "esbuild-plugin-tsc"; + +await Promise.all([ + esbuild.build({ + entryPoints: ["src/index.ts"], + bundle: true, + outfile: "dist/esm.js", + plugins: [tscPlugin({ force: true })], + external: ["uuidv7", "isows"], + format: "esm", + minify: true, + }), + esbuild.build({ + entryPoints: ["src/index.ts"], + bundle: true, + outfile: "dist/cjs.js", + plugins: [tscPlugin({ force: true })], + external: ["uuidv7", "isows"], + format: "cjs", + minify: true, + }), + esbuild.build({ + entryPoints: ["src/index.ts"], + bundle: true, + outfile: "dist/esm.bundled.js", + plugins: [tscPlugin({ force: true })], + format: "esm", + minify: true, + }), +]); + +Bun.spawn([ + "bunx", + "dts-bundle-generator", + "-o", + "dist/types.d.ts", + "src/index.ts", + "--no-check", + "--export-referenced-types", + "false", +]); diff --git a/scripts/jsr.ts b/scripts/jsr.ts new file mode 100644 index 00000000..1a050188 --- /dev/null +++ b/scripts/jsr.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import { version } from "../package.json"; + +const config = { + name: "@surrealdb/surrealdb", + version, + exports: "./src/index.ts", + publish: { + include: ["LICENSE", "README.md", "SECURITY.md", "src/**/*.ts"], + }, +}; + +const file = path.join(path.dirname(import.meta.dir), "jsr.json"); +await Bun.write(file, JSON.stringify(config, null, 2)); diff --git a/src/cbor/constants.ts b/src/cbor/constants.ts new file mode 100644 index 00000000..403d0f44 --- /dev/null +++ b/src/cbor/constants.ts @@ -0,0 +1,4 @@ +export type Replacer = (v: unknown) => unknown; +export type Major = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; +export const POW_2_53: number = 2 ** 53; +export const POW_2_64: bigint = BigInt(2 ** 64); diff --git a/src/cbor/decoder.ts b/src/cbor/decoder.ts new file mode 100644 index 00000000..ba571a20 --- /dev/null +++ b/src/cbor/decoder.ts @@ -0,0 +1,123 @@ +import type { Replacer } from "./constants"; +import { CborBreak, CborInvalidMajorError } from "./error"; +import { Reader } from "./reader"; +import { Tagged } from "./tagged"; +import { infiniteBytes } from "./util"; + +interface DecodeOptions { + map?: "object" | "map"; + replacer?: Replacer; +} + +export function decode( + input: ArrayBufferLike | Reader, + options: DecodeOptions = {}, + // biome-ignore lint/suspicious/noExplicitAny: Don't know what it will return +): any { + const inner = () => { + const r = input instanceof Reader ? input : new Reader(input); + const [major, len] = r.readMajor(); + switch (major) { + case 0: + return r.readMajorLength(len); + case 1: { + const l = r.readMajorLength(len); + return typeof l === "bigint" ? -(l + 1n) : -(l + 1); + } + case 2: { + if (len === 31) return infiniteBytes(r, 2); + return r.readBytes(Number(r.readMajorLength(len))).buffer; + } + case 3: { + const encoded = + len === 31 + ? infiniteBytes(r, 3) + : r.readBytes(Number(r.readMajorLength(len))); + + const textDecoder = new TextDecoder(); + return textDecoder.decode(encoded); + } + + case 4: { + if (len === 31) { + const arr: unknown[] = []; + while (true) { + try { + arr.push(decode(r, options)); + } catch (e) { + if (e instanceof CborBreak) break; + throw e; + } + } + + return arr; + } + + return new Array(r.readMajorLength(len)) + .fill(0) + .map(() => decode(r, options)); + } + + case 5: { + const map = new Map(); + + if (len === 31) { + while (true) { + let key: unknown; + try { + key = decode(r, options); + } catch (e) { + if (e instanceof CborBreak) break; + throw e; + } + + const value = decode(r, options); + map.set(key, value); + } + } else { + const l = r.readMajorLength(len); + for (let i = 0; i < l; i++) { + const key = decode(r, options); + const value = decode(r, options); + map.set(key, value); + } + } + + return options.map !== "map" ? Object.fromEntries(map.entries()) : map; + } + + case 6: { + const tag = r.readMajorLength(len); + const value = decode(r, options); + return new Tagged(tag, value); + } + + case 7: { + switch (len) { + case 20: + return false; + case 21: + return true; + case 22: + return null; + case 23: + return undefined; + case 25: + return r.readFloat16(); + case 26: + return r.readFloat32(); + case 27: + return r.readFloat64(); + case 31: + throw new CborBreak(); + } + } + } + + throw new CborInvalidMajorError( + `Unable to decode value with major tag ${major}`, + ); + }; + + return options.replacer ? options.replacer(inner()) : inner(); +} diff --git a/src/cbor/encoded.ts b/src/cbor/encoded.ts new file mode 100644 index 00000000..48555fa6 --- /dev/null +++ b/src/cbor/encoded.ts @@ -0,0 +1,3 @@ +export class Encoded { + constructor(readonly encoded: ArrayBuffer) {} +} diff --git a/src/cbor/encoder.ts b/src/cbor/encoder.ts new file mode 100644 index 00000000..32e8b8c9 --- /dev/null +++ b/src/cbor/encoder.ts @@ -0,0 +1,130 @@ +import { POW_2_53, POW_2_64, type Replacer } from "./constants"; +import { Encoded } from "./encoded"; +import { CborNumberError, CborPartialDisabled } from "./error"; +import { type Fill, Gap } from "./gap"; +import { PartiallyEncoded } from "./partial"; +import { Tagged } from "./tagged"; +import { Writer } from "./writer"; + +interface EncoderOptions { + replacer?: Replacer; + writer?: Writer; + partial?: Partial; + fills?: Fill[]; +} + +export function encode( + input: unknown, + options: EncoderOptions = {}, +): Partial extends true ? PartiallyEncoded : ArrayBuffer { + const w = options.writer ?? new Writer(); + const value = options.replacer ? options.replacer(input) : input; + const encodeOptions = { ...options, writer: w }; + const fillsMap = new Map(options.fills ?? []); + + if (value === undefined) { + w.writeUint8(0xf7); + } else if (value === null) { + w.writeUint8(0xf6); + } else if (value === true) { + w.writeUint8(0xf5); + } else if (value === false) { + w.writeUint8(0xf4); + } else if (value instanceof Gap) { + if (fillsMap.has(value)) { + encode(fillsMap.get(value), encodeOptions); + } else { + if (!options.partial) throw new CborPartialDisabled(); + w.chunk(value); + } + } else if (value instanceof PartiallyEncoded) { + const res = value.build(options.fills ?? [], options.partial); + if (options.partial) { + w.writePartiallyEncoded(res as PartiallyEncoded); + } else { + w.writeArrayBuffer(res as ArrayBuffer); + } + } else if (value instanceof Encoded) { + w.writeArrayBuffer(value.encoded); + } else { + switch (typeof value) { + case "number": { + if (Number.isInteger(value)) { + if (value >= 0 && value <= POW_2_53) { + w.writeMajor(0, value); + } else if (value < 0 && value >= -POW_2_53) { + w.writeMajor(1, -(value + 1)); + } else { + throw new CborNumberError("Number too big to be encoded"); + } + } else { + // Better precision when encoded as 64-bit + w.writeUint8(0xfb); + w.writeFloat64(value); + } + + break; + } + + case "bigint": { + if (value >= 0 && value < POW_2_64) { + w.writeMajor(0, value); + } else if (value <= 0 && value >= -POW_2_64) { + w.writeMajor(1, -(value + 1n)); + } else { + throw new CborNumberError("BigInt too big to be encoded"); + } + + break; + } + + case "string": { + const textEncoder = new TextEncoder(); + const encoded = textEncoder.encode(value); + w.writeMajor(3, encoded.byteLength); + w.writeUint8Array(encoded); + break; + } + + default: { + if (Array.isArray(value)) { + w.writeMajor(4, value.length); + for (const v of value) { + encode(v, encodeOptions); + } + } else if (value instanceof Tagged) { + w.writeMajor(6, value.tag); + encode(value.value, encodeOptions); + } else if ( + value instanceof Uint8Array || + value instanceof Uint16Array || + value instanceof Uint32Array || + value instanceof Int8Array || + value instanceof Int16Array || + value instanceof Int32Array || + value instanceof Float32Array || + value instanceof Float64Array || + value instanceof ArrayBuffer + ) { + const v = new Uint8Array(value); + w.writeMajor(2, v.byteLength); + w.writeUint8Array(v); + } else { + const entries = + value instanceof Map + ? Array.from(value.entries()) + : Object.entries(value); + + w.writeMajor(5, entries.length); + for (const v of entries.flat()) { + encode(v, encodeOptions); + } + } + + break; + } + } + } + + return w.output(!!options.partial as Partial, options.replacer); +} diff --git a/src/cbor/error.ts b/src/cbor/error.ts new file mode 100644 index 00000000..b0f538d3 --- /dev/null +++ b/src/cbor/error.ts @@ -0,0 +1,46 @@ +import { SurrealDbError } from "../errors"; + +export abstract class CborError extends SurrealDbError { + abstract readonly name: string; + readonly message: string; + + constructor(message: string) { + super(); + this.message = message; + } +} + +export class CborNumberError extends CborError { + name = "CborNumberError"; +} + +export class CborRangeError extends CborError { + name = "CborRangeError"; +} + +export class CborInvalidMajorError extends CborError { + name = "CborInvalidMajorError"; +} + +export class CborBreak extends CborError { + name = "CborBreak"; + constructor() { + super("Came across a break which was not intercepted by the decoder"); + } +} + +export class CborPartialDisabled extends CborError { + name = "CborPartialDisabled"; + constructor() { + super( + "Tried to insert a Gap into a CBOR value, while partial mode is not enabled", + ); + } +} + +export class CborFillMissing extends CborError { + name = "CborFillMissing"; + constructor() { + super("Fill for a gap is missing, and gap has no default"); + } +} diff --git a/src/cbor/gap.ts b/src/cbor/gap.ts new file mode 100644 index 00000000..18848a30 --- /dev/null +++ b/src/cbor/gap.ts @@ -0,0 +1,22 @@ +// Why is the default value being stored in an array? undefined, null, false, etc... are all valid defaults, +// and specifying a field on a class as optional will make it undefined by default. + +export type Fill = [Gap, T]; +export class Gap { + readonly args: [T?] = []; + constructor(...args: [T?]) { + this.args = args; + } + + fill(value: T): Fill { + return [this, value]; + } + + hasDefault(): boolean { + return this.args.length === 1; + } + + get default(): T | undefined { + return this.args[0]; + } +} diff --git a/src/cbor/index.ts b/src/cbor/index.ts new file mode 100644 index 00000000..cb936391 --- /dev/null +++ b/src/cbor/index.ts @@ -0,0 +1,11 @@ +export * from "./constants"; +export * from "./encoder"; +export * from "./decoder"; +export * from "./util"; +export * from "./reader"; +export * from "./writer"; +export * from "./tagged"; +export * from "./error"; +export * from "./gap"; +export * from "./partial"; +export * from "./encoded"; diff --git a/src/cbor/partial.ts b/src/cbor/partial.ts new file mode 100644 index 00000000..38274a6c --- /dev/null +++ b/src/cbor/partial.ts @@ -0,0 +1,52 @@ +import type { Replacer } from "./constants"; +import { encode } from "./encoder"; +import { CborFillMissing } from "./error"; +import type { Fill, Gap } from "./gap"; +import { Writer } from "./writer"; + +export class PartiallyEncoded { + constructor( + readonly chunks: [ArrayBuffer, Gap][], + readonly end: ArrayBuffer, + readonly replacer: Replacer | undefined, + ) {} + + build( + fills: Fill[], + partial?: Partial, + ): Partial extends true ? PartiallyEncoded : ArrayBuffer { + const writer = new Writer(); + const map = new Map(fills); + + for (const [buffer, gap] of this.chunks) { + const hasValue = map.has(gap) || gap.hasDefault(); + if (!partial && !hasValue) throw new CborFillMissing(); + writer.writeArrayBuffer(buffer); + + if (hasValue) { + const data = map.get(gap) ?? gap.default; + encode(data, { + writer, + replacer: this.replacer, + }); + } else { + writer.chunk(gap); + } + } + + writer.writeArrayBuffer(this.end); + return writer.output(!!partial as Partial, this.replacer); + } +} + +export function partiallyEncodeObject( + object: Record, + fills?: Fill[], +): Record { + return Object.fromEntries( + Object.entries(object).map(([k, v]) => [ + k, + encode(v, { fills, partial: true }), + ]), + ); +} diff --git a/src/cbor/reader.ts b/src/cbor/reader.ts new file mode 100644 index 00000000..7f3ee6f2 --- /dev/null +++ b/src/cbor/reader.ts @@ -0,0 +1,134 @@ +import { type Major, POW_2_53 } from "./constants"; +import { CborInvalidMajorError, CborRangeError } from "./error"; + +export class Reader { + private _buf: ArrayBufferLike; + private _view: DataView; + private _byte: Uint8Array; + private _pos = 0; + + constructor(buffer: ArrayBufferLike) { + this._buf = new ArrayBuffer(buffer.byteLength); + this._view = new DataView(this._buf); + this._byte = new Uint8Array(this._buf); + this._byte.set(new Uint8Array(buffer)); + } + + get left(): Uint8Array { + return this._byte.slice(this._pos); + } + + private read(amount: number, res: T): T { + this._pos += amount; + return res; + } + + readUint8(): number { + try { + return this.read(1, this._view.getUint8(this._pos)); + } catch (e) { + if (e instanceof RangeError) throw new CborRangeError(e.message); + throw e; + } + } + + readUint16(): number { + try { + return this.read(2, this._view.getUint16(this._pos)); + } catch (e) { + if (e instanceof RangeError) throw new CborRangeError(e.message); + throw e; + } + } + + readUint32(): number { + try { + return this.read(4, this._view.getUint32(this._pos)); + } catch (e) { + if (e instanceof RangeError) throw new CborRangeError(e.message); + throw e; + } + } + + readUint64(): bigint { + try { + return this.read(8, this._view.getBigUint64(this._pos)); + } catch (e) { + if (e instanceof RangeError) throw new CborRangeError(e.message); + throw e; + } + } + + // https://stackoverflow.com/a/5684578 + readFloat16(): number { + const bytes = this.readUint16(); + const s = (bytes & 0x8000) >> 15; + const e = (bytes & 0x7c00) >> 10; + const f = bytes & 0x03ff; + + if (e === 0) { + return (s ? -1 : 1) * 2 ** -14 * (f / 2 ** 10); + } + + if (e === 0x1f) { + return f ? Number.NaN : (s ? -1 : 1) * Number.POSITIVE_INFINITY; + } + + return (s ? -1 : 1) * 2 ** (e - 15) * (1 + f / 2 ** 10); + } + + readFloat32(): number { + try { + return this.read(4, this._view.getFloat32(this._pos)); + } catch (e) { + if (e instanceof RangeError) throw new CborRangeError(e.message); + throw e; + } + } + + readFloat64(): number { + try { + return this.read(8, this._view.getFloat64(this._pos)); + } catch (e) { + if (e instanceof RangeError) throw new CborRangeError(e.message); + throw e; + } + } + + readBytes(amount: number): Uint8Array { + const available = this.left.length; + if (amount > available) + throw new CborRangeError( + `The argument must be between 0 and ${available}`, + ); + + return this.read(amount, this._byte.slice(this._pos, this._pos + amount)); + } + + readMajor(): [Major, number] { + const byte = this.readUint8(); + const major = (byte >> 5) as Major; + if (major < 0 || major > 7) + throw new CborInvalidMajorError("Received invalid major type"); + return [major, byte & 0x1f]; + } + + readMajorLength(length: number): number | bigint { + if (length <= 23) return length; + + switch (length) { + case 24: + return this.readUint8(); + case 25: + return this.readUint16(); + case 26: + return this.readUint32(); + case 27: { + const read = this.readUint64(); + return read > POW_2_53 ? read : Number(read); + } + } + + throw new CborRangeError("Expected a final length"); + } +} diff --git a/src/cbor/tagged.ts b/src/cbor/tagged.ts new file mode 100644 index 00000000..58873377 --- /dev/null +++ b/src/cbor/tagged.ts @@ -0,0 +1,6 @@ +export class Tagged { + constructor( + readonly tag: number | bigint, + readonly value: T, + ) {} +} diff --git a/src/cbor/util.ts b/src/cbor/util.ts new file mode 100644 index 00000000..3ea14c40 --- /dev/null +++ b/src/cbor/util.ts @@ -0,0 +1,30 @@ +import type { Major } from "./constants"; +import { CborInvalidMajorError, CborRangeError } from "./error"; +import type { Reader } from "./reader"; +import { Writer } from "./writer"; + +export function infiniteBytes(r: Reader, forMajor: Major): ArrayBuffer { + const w = new Writer(); + while (true) { + const [major, len] = r.readMajor(); + + // Received break signal + if (major === 7 && len === 31) break; + + // Resource type has to match + if (major !== forMajor) + throw new CborInvalidMajorError( + `Expected a resource of the same major (${forMajor}) while processing an infinite resource`, + ); + + // Cannot have an infinite resource in an infinite resource + if (len === 31) + throw new CborRangeError( + "Expected a finite resource while processing an infinite resource", + ); + + w.writeUint8Array(r.readBytes(Number(r.readMajorLength(len)))); + } + + return w.buffer; +} diff --git a/src/cbor/writer.ts b/src/cbor/writer.ts new file mode 100644 index 00000000..34966c8f --- /dev/null +++ b/src/cbor/writer.ts @@ -0,0 +1,125 @@ +import type { Major, Replacer } from "./constants"; +import type { Gap } from "./gap"; +import { PartiallyEncoded } from "./partial"; + +export class Writer { + private _chunks: [ArrayBuffer, Gap][] = []; + private _buf: ArrayBuffer; + private _view: DataView; + private _byte: Uint8Array; + + constructor() { + this._buf = new ArrayBuffer(0); + this._view = new DataView(this._buf); + this._byte = new Uint8Array(this._buf); + } + + chunk(gap: Gap): void { + this._chunks.push([this._buf, gap]); + this._buf = new ArrayBuffer(0); + this._view = new DataView(this._buf); + this._byte = new Uint8Array(this._buf); + } + + get chunks(): [ArrayBuffer, Gap][] { + return this._chunks; + } + + get buffer(): ArrayBuffer { + return this._buf; + } + + private claim(length: number) { + const pos = this._buf.byteLength; + const oldb = this._byte; + this._buf = new ArrayBuffer(pos + length); + this._view = new DataView(this._buf); + this._byte = new Uint8Array(this._buf); + this._byte.set(oldb); + return pos; + } + + writeUint8(value: number): void { + const pos = this.claim(1); + this._view.setUint8(pos, value); + } + + writeUint16(value: number): void { + const pos = this.claim(2); + this._view.setUint16(pos, value); + } + + writeUint32(value: number): void { + const pos = this.claim(4); + this._view.setUint32(pos, value); + } + + writeUint64(value: bigint): void { + const pos = this.claim(8); + this._view.setBigUint64(pos, value); + } + + writeUint8Array(data: Uint8Array): void { + if (data.byteLength === 0) return; + const pos = this.claim(data.byteLength); + this._byte.set(data, pos); + } + + writeArrayBuffer(data: ArrayBuffer): void { + if (data.byteLength === 0) return; + this.writeUint8Array(new Uint8Array(data)); + } + + writePartiallyEncoded(data: PartiallyEncoded): void { + for (const [buf, gap] of data.chunks) { + this.writeArrayBuffer(buf); + this.chunk(gap); + } + + this.writeArrayBuffer(data.end); + } + + writeFloat32(value: number): void { + const pos = this.claim(4); + this._view.setFloat32(pos, value); + } + + writeFloat64(value: number): void { + const pos = this.claim(8); + this._view.setFloat64(pos, value); + } + + writeMajor(type: Major, length: number | bigint): void { + const base = type << 5; + if (length < 24) { + this.writeUint8(base + Number(length)); + } else if (length < 0x100) { + this.writeUint8(base + 24); + this.writeUint8(Number(length)); + } else if (length < 0x10000) { + this.writeUint8(base + 25); + this.writeUint16(Number(length)); + } else if (length < 0x100000000) { + this.writeUint8(base + 26); + this.writeUint32(Number(length)); + } else { + this.writeUint8(base + 27); + this.writeUint64(BigInt(length)); + } + } + + output( + partial: Partial, + replacer?: Replacer, + ): Partial extends true ? PartiallyEncoded : ArrayBuffer { + if (partial) { + return new PartiallyEncoded( + this._chunks, + this._buf, + replacer, + ) as Partial extends true ? PartiallyEncoded : ArrayBuffer; + } + + return this._buf as Partial extends true ? PartiallyEncoded : ArrayBuffer; + } +} diff --git a/src/library/cbor/index.ts b/src/data/cbor.ts similarity index 53% rename from src/library/cbor/index.ts rename to src/data/cbor.ts index 9792680d..77274859 100644 --- a/src/library/cbor/index.ts +++ b/src/data/cbor.ts @@ -1,16 +1,10 @@ +import { Tagged, decode, encode } from "../cbor"; import { - decode as decode_cbor, - encode as encode_cbor, - TaggedValue, -} from "npm:cbor-redux@1.0.0"; -import { RecordId, StringRecordId } from "./recordid.ts"; -import { UUID, uuidv4, uuidv7 } from "./uuid.ts"; -import { - cborCustomDurationToDuration, - Duration, - durationToCborCustomDuration, -} from "./duration.ts"; -import { Decimal } from "./decimal.ts"; + cborCustomDateToDate, + dateToCborCustomDate, +} from "./types/datetime.ts"; +import { Decimal } from "./types/decimal.ts"; +import { Duration } from "./types/duration.ts"; import { GeometryCollection, GeometryLine, @@ -19,9 +13,10 @@ import { GeometryMultiPolygon, GeometryPoint, GeometryPolygon, -} from "./geometry.ts"; -import { Table } from "./table.ts"; -import { cborCustomDateToDate, dateToCborCustomDate } from "./datetime.ts"; +} from "./types/geometry.ts"; +import { RecordId, StringRecordId } from "./types/recordid.ts"; +import { Table } from "./types/table.ts"; +import { Uuid } from "./types/uuid.ts"; // Tags from the spec - https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml const TAG_SPEC_DATETIME = 0; @@ -47,80 +42,70 @@ const TAG_GEOMETRY_MULTILINE = 92; const TAG_GEOMETRY_MULTIPOLYGON = 93; const TAG_GEOMETRY_COLLECTION = 94; -export function encodeCbor(data: T) { - return encode_cbor(data, (_, v) => { +export const replacer = { + encode(v: unknown): unknown { if (v instanceof Date) { - return new TaggedValue( - dateToCborCustomDate(v), - TAG_CUSTOM_DATETIME, - ); + return new Tagged(TAG_CUSTOM_DATETIME, dateToCborCustomDate(v)); } - if (v === undefined) return new TaggedValue(null, TAG_NONE); - if (v instanceof UUID) { - return new TaggedValue(v.bytes.buffer, TAG_SPEC_UUID); + if (v === undefined) return new Tagged(TAG_NONE, null); + if (v instanceof Uuid) { + return new Tagged(TAG_SPEC_UUID, v.toBuffer()); } if (v instanceof Decimal) { - return new TaggedValue(v.toString(), TAG_STRING_DECIMAL); + return new Tagged(TAG_STRING_DECIMAL, v.toString()); } if (v instanceof Duration) { - return new TaggedValue( - durationToCborCustomDuration(v), - TAG_CUSTOM_DURATION, - ); + return new Tagged(TAG_CUSTOM_DURATION, v.toCompact()); } if (v instanceof RecordId) { - return new TaggedValue([v.tb, v.id], TAG_RECORDID); + return new Tagged(TAG_RECORDID, [v.tb, v.id]); } if (v instanceof StringRecordId) { - return new TaggedValue(v.rid, TAG_RECORDID); + return new Tagged(TAG_RECORDID, v.rid); } - if (v instanceof Table) return new TaggedValue(v.tb, TAG_TABLE); + if (v instanceof Table) return new Tagged(TAG_TABLE, v.tb); if (v instanceof GeometryPoint) { - return new TaggedValue(v.point, TAG_GEOMETRY_POINT); + return new Tagged(TAG_GEOMETRY_POINT, v.point); } if (v instanceof GeometryLine) { - return new TaggedValue(v.line, TAG_GEOMETRY_LINE); + return new Tagged(TAG_GEOMETRY_LINE, v.line); } if (v instanceof GeometryPolygon) { - return new TaggedValue(v.polygon, TAG_GEOMETRY_POLYGON); + return new Tagged(TAG_GEOMETRY_POLYGON, v.polygon); } if (v instanceof GeometryMultiPoint) { - return new TaggedValue(v.points, TAG_GEOMETRY_MULTIPOINT); + return new Tagged(TAG_GEOMETRY_MULTIPOINT, v.points); } if (v instanceof GeometryMultiLine) { - return new TaggedValue(v.lines, TAG_GEOMETRY_MULTILINE); + return new Tagged(TAG_GEOMETRY_MULTILINE, v.lines); } if (v instanceof GeometryMultiPolygon) { - return new TaggedValue(v.polygons, TAG_GEOMETRY_MULTIPOLYGON); + return new Tagged(TAG_GEOMETRY_MULTIPOLYGON, v.polygons); } if (v instanceof GeometryCollection) { - return new TaggedValue(v.collection, TAG_GEOMETRY_COLLECTION); + return new Tagged(TAG_GEOMETRY_COLLECTION, v.collection); } return v; - }); -} - -export function decodeCbor(data: ArrayBuffer) { - return decode_cbor(data, (_, v) => { - if (!(v instanceof TaggedValue)) return v; + }, + decode(v: unknown): unknown { + if (!(v instanceof Tagged)) return v; switch (v.tag) { case TAG_SPEC_DATETIME: return new Date(v.value); case TAG_SPEC_UUID: - return UUID.ofInner(new Uint8Array(v.value)); + case TAG_STRING_UUID: + return new Uuid(v.value); case TAG_CUSTOM_DATETIME: return cborCustomDateToDate(v.value); case TAG_NONE: return undefined; - case TAG_STRING_UUID: - return UUID.parse(v.value); case TAG_STRING_DECIMAL: return new Decimal(v.value); case TAG_STRING_DURATION: return new Duration(v.value); case TAG_CUSTOM_DURATION: - return cborCustomDurationToDuration(v.value); + return Duration.fromCompact(v.value); case TAG_TABLE: return new Table(v.value); case TAG_RECORDID: @@ -140,23 +125,20 @@ export function decodeCbor(data: ArrayBuffer) { case TAG_GEOMETRY_COLLECTION: return new GeometryCollection(v.value); } + }, +}; + +Object.freeze(replacer); + +export function encodeCbor(data: T): ArrayBuffer { + return encode(data, { + replacer: replacer.encode, }); } -export { - Decimal, - Duration, - GeometryCollection, - GeometryLine, - GeometryMultiLine, - GeometryMultiPoint, - GeometryMultiPolygon, - GeometryPoint, - GeometryPolygon, - RecordId, - StringRecordId, - Table, - UUID, - uuidv4, - uuidv7, -}; +// biome-ignore lint/suspicious/noExplicitAny: Don't know what it will return +export function decodeCbor(data: ArrayBufferLike): any { + return decode(data, { + replacer: replacer.decode, + }); +} diff --git a/src/data/index.ts b/src/data/index.ts new file mode 100644 index 00000000..522799c9 --- /dev/null +++ b/src/data/index.ts @@ -0,0 +1,16 @@ +export { RecordId, StringRecordId } from "./types/recordid.ts"; +export { Uuid } from "./types/uuid.ts"; +export { Duration } from "./types/duration.ts"; +export { Decimal } from "./types/decimal.ts"; +export { Table } from "./types/table.ts"; +export { + Geometry, + GeometryCollection, + GeometryLine, + GeometryMultiLine, + GeometryMultiPoint, + GeometryMultiPolygon, + GeometryPoint, + GeometryPolygon, +} from "./types/geometry.ts"; +export { encodeCbor, decodeCbor } from "./cbor.ts"; diff --git a/src/library/cbor/datetime.ts b/src/data/types/datetime.ts similarity index 57% rename from src/library/cbor/datetime.ts rename to src/data/types/datetime.ts index c0975f0e..d1091b51 100644 --- a/src/library/cbor/datetime.ts +++ b/src/data/types/datetime.ts @@ -1,18 +1,18 @@ -export function msToNs(ms: number) { +export function msToNs(ms: number): number { return ms * 1000000; } -export function nsToMs(ns: number) { +export function nsToMs(ns: number): number { return Math.floor(ns / 1000000); } -export function dateToCborCustomDate(date: Date) { +export function dateToCborCustomDate(date: Date): [number, number] { const s = Math.floor(date.getTime() / 1000); const ms = date.getTime() - s * 1000; return [s, ms * 1000000]; } -export function cborCustomDateToDate([s, ns]: [number, number]) { +export function cborCustomDateToDate([s, ns]: [number, number]): Date { const date = new Date(0); date.setUTCSeconds(Number(s)); date.setMilliseconds(Math.floor(Number(ns) / 1000000)); diff --git a/src/data/types/decimal.ts b/src/data/types/decimal.ts new file mode 100644 index 00000000..9978a160 --- /dev/null +++ b/src/data/types/decimal.ts @@ -0,0 +1,15 @@ +export class Decimal { + readonly decimal: string; + + constructor(decimal: string | number | Decimal) { + this.decimal = decimal.toString(); + } + + toString(): string { + return this.decimal; + } + + toJSON(): string { + return this.decimal; + } +} diff --git a/src/data/types/duration.ts b/src/data/types/duration.ts new file mode 100644 index 00000000..d853ad79 --- /dev/null +++ b/src/data/types/duration.ts @@ -0,0 +1,164 @@ +const millisecond = 1; +const microsecond = millisecond / 1000; +const nanosecond = microsecond / 1000; +const second = 1000 * millisecond; +const minute = 60 * second; +const hour = 60 * minute; +const day = 24 * hour; +const week = 7 * day; + +const units = new Map([ + ["ns", nanosecond], + ["µs", microsecond], + ["μs", microsecond], // They look similar, but this unit is a different charachter than the one above it. + ["us", microsecond], // needs to come last to be the displayed unit + ["ms", millisecond], + ["s", second], + ["m", minute], + ["h", hour], + ["d", day], + ["w", week], +]); + +const unitsReverse = Array.from(units).reduce((map, [unit, size]) => { + map.set(size, unit); + return map; +}, new Map()); + +const durationPartRegex = new RegExp( + `^(\\d+)(${Array.from(units.keys()).join("|")})`, +); + +export class Duration { + readonly _milliseconds: number; + + constructor(input: Duration | number | string) { + if (input instanceof Duration) { + this._milliseconds = input._milliseconds; + } else if (typeof input === "string") { + this._milliseconds = Duration.parseString(input); + } else { + this._milliseconds = input; + } + } + + static fromCompact([s, ns]: [number, number] | [number] | []): Duration { + s = s ?? 0; + ns = ns ?? 0; + const ms = s * 1000 + ns / 1000000; + return new Duration(ms); + } + + toCompact(): [number, number] | [number] | [] { + const s = Math.floor(this._milliseconds / 1000); + const ns = Math.floor((this._milliseconds - s * 1000) * 1000000); + return ns > 0 ? [s, ns] : s > 0 ? [s] : []; + } + + toString(): string { + let left = this._milliseconds; + let result = ""; + function scrap(size: number) { + const num = Math.floor(left / size); + if (num > 0) left = left % size; + return num; + } + + for (const [size, unit] of Array.from(unitsReverse).reverse()) { + const scrapped = scrap(size); + if (scrapped > 0) result += `${scrapped}${unit}`; + } + + return result; + } + + toJSON(): string { + return this.toString(); + } + + static parseString(input: string): number { + let ms = 0; + let left = input; + while (left !== "") { + const match = left.match(durationPartRegex); + if (match) { + const amount = Number.parseInt(match[1]); + const factor = units.get(match[2]); + if (factor === undefined) + throw new Error(`Invalid duration unit: ${match[2]}`); + + ms += amount * factor; + left = left.slice(match[0].length); + continue; + } + + throw new Error("Could not match a next duration part"); + } + + return ms; + } + + static nanoseconds(nanoseconds: number): Duration { + return new Duration(Math.floor(nanoseconds * nanosecond)); + } + + static microseconds(microseconds: number): Duration { + return new Duration(Math.floor(microseconds * microsecond)); + } + + static milliseconds(milliseconds: number): Duration { + return new Duration(milliseconds); + } + + static seconds(seconds: number): Duration { + return new Duration(seconds * second); + } + + static minutes(minutes: number): Duration { + return new Duration(minutes * minute); + } + + static hours(hours: number): Duration { + return new Duration(hours * hour); + } + + static days(days: number): Duration { + return new Duration(days * day); + } + + static weeks(weeks: number): Duration { + return new Duration(weeks * week); + } + + get microseconds(): number { + return Math.floor(this._milliseconds / microsecond); + } + + get nanoseconds(): number { + return Math.floor(this._milliseconds / nanosecond); + } + + get milliseconds(): number { + return Math.floor(this._milliseconds); + } + + get seconds(): number { + return Math.floor(this._milliseconds / second); + } + + get minutes(): number { + return Math.floor(this._milliseconds / minute); + } + + get hours(): number { + return Math.floor(this._milliseconds / hour); + } + + get days(): number { + return Math.floor(this._milliseconds / day); + } + + get weeks(): number { + return Math.floor(this._milliseconds / week); + } +} diff --git a/src/data/types/geometry.ts b/src/data/types/geometry.ts new file mode 100644 index 00000000..e3acb979 --- /dev/null +++ b/src/data/types/geometry.ts @@ -0,0 +1,366 @@ +import { Decimal } from "./decimal.ts"; + +export abstract class Geometry { + abstract toJSON(): GeoJson; + abstract is(geometry: Geometry): boolean; + abstract clone(): Geometry; +} + +function f(num: number | Decimal) { + if (num instanceof Decimal) return Number.parseFloat(num.decimal); + return num; +} + +export class GeometryPoint extends Geometry { + readonly point: [number, number]; + + constructor(point: [number | Decimal, number | Decimal] | GeometryPoint) { + super(); + if (point instanceof GeometryPoint) { + this.point = point.clone().point; + } else { + this.point = [f(point[0]), f(point[1])]; + } + } + + toJSON(): GeoJsonPoint { + return { + type: "Point" as const, + coordinates: this.coordinates, + }; + } + + get coordinates(): GeoJsonPoint["coordinates"] { + return this.point; + } + + is(geometry: Geometry): geometry is GeometryPoint { + if (!(geometry instanceof GeometryPoint)) return false; + return ( + this.point[0] === geometry.point[0] && this.point[1] === geometry.point[1] + ); + } + + clone(): GeometryPoint { + return new GeometryPoint([...this.point]); + } +} + +export class GeometryLine extends Geometry { + readonly line: [GeometryPoint, GeometryPoint, ...GeometryPoint[]]; + + // SurrealDB only has the concept of a "Line", which by spec is two points. + // SurrealDB's "Line" however, is actually a "LineString" under the hood, which accepts two or more points + constructor( + line: [GeometryPoint, GeometryPoint, ...GeometryPoint[]] | GeometryLine, + ) { + super(); + this.line = line instanceof GeometryLine ? line.clone().line : line; + } + + toJSON(): GeoJsonLineString { + return { + type: "LineString" as const, + coordinates: this.coordinates, + }; + } + + get coordinates(): GeoJsonLineString["coordinates"] { + return this.line.map( + (g) => g.coordinates, + ) as GeoJsonLineString["coordinates"]; + } + + close(): void { + if (!this.line[0].is(this.line.at(-1) as GeometryPoint)) { + this.line.push(this.line[0]); + } + } + + is(geometry: Geometry): geometry is GeometryLine { + if (!(geometry instanceof GeometryLine)) return false; + if (this.line.length !== geometry.line.length) return false; + for (let i = 0; i < this.line.length; i++) { + if (!this.line[i].is(geometry.line[i])) return false; + } + + return true; + } + + clone(): GeometryLine { + return new GeometryLine( + this.line.map((p) => p.clone()) as [ + GeometryPoint, + GeometryPoint, + ...GeometryPoint[], + ], + ); + } +} + +export class GeometryPolygon extends Geometry { + readonly polygon: [GeometryLine, ...GeometryLine[]]; + + constructor(polygon: [GeometryLine, ...GeometryLine[]] | GeometryPolygon) { + super(); + this.polygon = + polygon instanceof GeometryPolygon + ? polygon.clone().polygon + : (polygon.map((l) => { + const line = l.clone(); + line.close(); + return line; + }) as [GeometryLine, ...GeometryLine[]]); + } + + toJSON(): GeoJsonPolygon { + return { + type: "Polygon" as const, + coordinates: this.coordinates, + }; + } + + get coordinates(): GeoJsonPolygon["coordinates"] { + return this.polygon.map( + (g) => g.coordinates, + ) as GeoJsonPolygon["coordinates"]; + } + + is(geometry: Geometry): geometry is GeometryPolygon { + if (!(geometry instanceof GeometryPolygon)) return false; + if (this.polygon.length !== geometry.polygon.length) return false; + for (let i = 0; i < this.polygon.length; i++) { + if (!this.polygon[i].is(geometry.polygon[i])) return false; + } + + return true; + } + + clone(): GeometryPolygon { + return new GeometryPolygon( + this.polygon.map((p) => p.clone()) as [GeometryLine, ...GeometryLine[]], + ); + } +} + +export class GeometryMultiPoint extends Geometry { + readonly points: [GeometryPoint, ...GeometryPoint[]]; + + constructor( + points: [GeometryPoint, ...GeometryPoint[]] | GeometryMultiPoint, + ) { + super(); + this.points = points instanceof GeometryMultiPoint ? points.points : points; + } + + toJSON(): GeoJsonMultiPoint { + return { + type: "MultiPoint" as const, + coordinates: this.coordinates, + }; + } + + get coordinates(): GeoJsonMultiPoint["coordinates"] { + return this.points.map( + (g) => g.coordinates, + ) as GeoJsonMultiPoint["coordinates"]; + } + + is(geometry: Geometry): geometry is GeometryMultiPoint { + if (!(geometry instanceof GeometryMultiPoint)) return false; + if (this.points.length !== geometry.points.length) return false; + for (let i = 0; i < this.points.length; i++) { + if (!this.points[i].is(geometry.points[i])) return false; + } + + return true; + } + + clone(): GeometryMultiPoint { + return new GeometryMultiPoint( + this.points.map((p) => p.clone()) as [GeometryPoint, ...GeometryPoint[]], + ); + } +} + +export class GeometryMultiLine extends Geometry { + readonly lines: [GeometryLine, ...GeometryLine[]]; + + constructor(lines: [GeometryLine, ...GeometryLine[]] | GeometryMultiLine) { + super(); + this.lines = lines instanceof GeometryMultiLine ? lines.lines : lines; + } + + toJSON(): GeoJsonMultiLineString { + return { + type: "MultiLineString" as const, + coordinates: this.coordinates, + }; + } + + get coordinates(): GeoJsonMultiLineString["coordinates"] { + return this.lines.map( + (g) => g.coordinates, + ) as GeoJsonMultiLineString["coordinates"]; + } + + is(geometry: Geometry): geometry is GeometryMultiLine { + if (!(geometry instanceof GeometryMultiLine)) return false; + if (this.lines.length !== geometry.lines.length) return false; + for (let i = 0; i < this.lines.length; i++) { + if (!this.lines[i].is(geometry.lines[i])) return false; + } + + return true; + } + + clone(): GeometryMultiLine { + return new GeometryMultiLine( + this.lines.map((p) => p.clone()) as [GeometryLine, ...GeometryLine[]], + ); + } +} + +export class GeometryMultiPolygon extends Geometry { + readonly polygons: [GeometryPolygon, ...GeometryPolygon[]]; + + constructor( + polygons: [GeometryPolygon, ...GeometryPolygon[]] | GeometryMultiPolygon, + ) { + super(); + this.polygons = + polygons instanceof GeometryMultiPolygon ? polygons.polygons : polygons; + } + + toJSON(): GeoJsonMultiPolygon { + return { + type: "MultiPolygon" as const, + coordinates: this.coordinates, + }; + } + + get coordinates(): GeoJsonMultiPolygon["coordinates"] { + return this.polygons.map( + (g) => g.coordinates, + ) as GeoJsonMultiPolygon["coordinates"]; + } + + is(geometry: Geometry): geometry is GeometryMultiPolygon { + if (!(geometry instanceof GeometryMultiPolygon)) return false; + if (this.polygons.length !== geometry.polygons.length) return false; + for (let i = 0; i < this.polygons.length; i++) { + if (!this.polygons[i].is(geometry.polygons[i])) return false; + } + + return true; + } + + clone(): GeometryMultiPolygon { + return new GeometryMultiPolygon( + this.polygons.map((p) => p.clone()) as [ + GeometryPolygon, + ...GeometryPolygon[], + ], + ); + } +} + +export class GeometryCollection extends Geometry { + readonly collection: [Geometry, ...Geometry[]]; + + constructor(collection: [Geometry, ...Geometry[]] | GeometryCollection) { + super(); + this.collection = + collection instanceof GeometryCollection + ? collection.collection + : collection; + } + + toJSON(): GeoJsonCollection { + return { + type: "GeometryCollection" as const, + geometries: this.geometries, + }; + } + + get geometries(): GeoJsonCollection["geometries"] { + return this.collection.map((g) => + g.toJSON(), + ) as GeoJsonCollection["geometries"]; + } + + is(geometry: Geometry): geometry is GeometryCollection { + if (!(geometry instanceof GeometryCollection)) return false; + if (this.collection.length !== geometry.collection.length) return false; + for (let i = 0; i < this.collection.length; i++) { + if (!this.collection[i].is(geometry.collection[i])) return false; + } + + return true; + } + + clone(): GeometryCollection { + return new GeometryCollection( + this.collection.map((p) => p.clone()) as [Geometry, ...Geometry[]], + ); + } +} + +// Geo Json Types + +type GeoJson = + | GeoJsonPoint + | GeoJsonLineString + | GeoJsonPolygon + | GeoJsonMultiPoint + | GeoJsonMultiLineString + | GeoJsonMultiPolygon + | GeoJsonCollection; + +export type GeoJsonPoint = { + type: "Point"; + coordinates: [number, number]; +}; + +export type GeoJsonLineString = { + type: "LineString"; + coordinates: [ + GeoJsonPoint["coordinates"], + GeoJsonPoint["coordinates"], + ...GeoJsonPoint["coordinates"][], + ]; +}; + +export type GeoJsonPolygon = { + type: "Polygon"; + coordinates: [ + GeoJsonLineString["coordinates"], + ...GeoJsonLineString["coordinates"][], + ]; +}; + +export type GeoJsonMultiPoint = { + type: "MultiPoint"; + coordinates: [GeoJsonPoint["coordinates"], ...GeoJsonPoint["coordinates"][]]; +}; + +export type GeoJsonMultiLineString = { + type: "MultiLineString"; + coordinates: [ + GeoJsonLineString["coordinates"], + ...GeoJsonLineString["coordinates"][], + ]; +}; + +export type GeoJsonMultiPolygon = { + type: "MultiPolygon"; + coordinates: [ + GeoJsonPolygon["coordinates"], + ...GeoJsonPolygon["coordinates"][], + ]; +}; + +export type GeoJsonCollection = { + type: "GeometryCollection"; + geometries: GeoJson[]; +}; diff --git a/src/data/types/recordid.ts b/src/data/types/recordid.ts new file mode 100644 index 00000000..c523b087 --- /dev/null +++ b/src/data/types/recordid.ts @@ -0,0 +1,101 @@ +const MAX_i64 = 9223372036854775807n; +export type RecordIdValue = + | string + | number + | bigint + | unknown[] + | Record; + +export class RecordId { + public readonly tb: Tb; + public readonly id: RecordIdValue; + + constructor(tb: Tb, id: RecordIdValue) { + if (typeof tb !== "string") throw new Error("TB part is not valid"); + if (!isValidIsPart(id)) throw new Error("ID part is not valid"); + + this.tb = tb; + this.id = id; + } + + toJSON(): string { + return this.toString(); + } + + toString(): string { + const tb = escape_ident(this.tb); + const id = + typeof this.id === "string" + ? escape_ident(this.id) + : typeof this.id === "bigint" || typeof this.id === "number" + ? escape_number(this.id) + : JSON.stringify(this.id); + return `${tb}:${id}`; + } +} + +export class StringRecordId { + public readonly rid: string; + + constructor(rid: string) { + if (typeof rid !== "string") + throw new Error("String Record ID must be a string"); + + this.rid = rid; + } + + toJSON(): string { + return this.rid; + } + + toString(): string { + return this.rid; + } +} + +function escape_number(num: number | bigint) { + return num <= MAX_i64 ? num.toString() : `⟨${num}⟩`; +} + +export function escape_ident(str: string): string { + // String which looks like a number should always be escaped, to prevent it from being parsed as a number + if (isOnlyNumbers(str)) { + return `⟨${str}⟩`; + } + + let code: number; + let i: number; + let len: number; + + for (i = 0, len = str.length; i < len; i++) { + code = str.charCodeAt(i); + if ( + !(code > 47 && code < 58) && // numeric (0-9) + !(code > 64 && code < 91) && // upper alpha (A-Z) + !(code > 96 && code < 123) && // lower alpha (a-z) + !(code === 95) // underscore (_) + ) { + return `⟨${str.replaceAll("⟩", "⟩")}⟩`; + } + } + + return str; +} + +function isOnlyNumbers(str: string) { + const parsed = Number.parseInt(str); + return !Number.isNaN(parsed) && parsed.toString() === str; +} + +function isValidIsPart(v: unknown): v is RecordIdValue { + switch (typeof v) { + case "string": + case "number": + case "bigint": + return true; + case "object": + return Array.isArray(v) || v !== null; + default: + return false; + } +} diff --git a/src/library/cbor/table.ts b/src/data/types/table.ts similarity index 52% rename from src/library/cbor/table.ts rename to src/data/types/table.ts index 4239fcba..7ad7ce88 100644 --- a/src/library/cbor/table.ts +++ b/src/data/types/table.ts @@ -1,17 +1,16 @@ -import { z } from "npm:zod"; - export class Table { public readonly tb: Tb; constructor(tb: Tb) { - this.tb = z.string().parse(tb) as Tb; + if (typeof tb !== "string") throw new Error("Table must be a string"); + this.tb = tb; } - toJSON() { + toJSON(): string { return this.tb; } - toString() { + toString(): string { return this.tb; } } diff --git a/src/data/types/uuid.ts b/src/data/types/uuid.ts new file mode 100644 index 00000000..a38cdb94 --- /dev/null +++ b/src/data/types/uuid.ts @@ -0,0 +1,43 @@ +import { UUID, uuidv4obj, uuidv7obj } from "uuidv7"; + +export class Uuid { + private readonly inner: UUID; + + constructor(uuid: string | ArrayBuffer | Uint8Array | Uuid | UUID) { + if (uuid instanceof ArrayBuffer) { + this.inner = UUID.ofInner(new Uint8Array(uuid)); + } else if (uuid instanceof Uint8Array) { + this.inner = UUID.ofInner(uuid); + } else if (uuid instanceof Uuid) { + this.inner = uuid.inner; + } else if (uuid instanceof UUID) { + this.inner = uuid; + } else { + this.inner = UUID.parse(uuid); + } + } + + toString(): string { + return this.inner.toString(); + } + + toJSON(): string { + return this.inner.toString(); + } + + toUint8Array(): Uint8Array { + return this.inner.bytes; + } + + toBuffer(): ArrayBufferLike { + return this.inner.bytes.buffer; + } + + static v4(): Uuid { + return new Uuid(uuidv4obj()); + } + + static v7(): Uuid { + return new Uuid(uuidv7obj()); + } +} diff --git a/src/engines/abstract.ts b/src/engines/abstract.ts new file mode 100644 index 00000000..948a60bb --- /dev/null +++ b/src/engines/abstract.ts @@ -0,0 +1,95 @@ +import type { Encoded } from "../cbor"; +import type { EngineDisconnected } from "../errors"; +import type { + LiveAction, + LiveHandlerArguments, + Patch, + RpcRequest, + RpcResponse, +} from "../types"; +import type { Emitter } from "../util/emitter"; + +export type Engine = new (context: EngineContext) => AbstractEngine; +export type Engines = Record; + +export type EngineEvents = { + connecting: []; + connected: []; + disconnected: []; + error: [Error]; + + [K: `rpc-${string | number}`]: [RpcResponse | EngineDisconnected]; + [K: `live-${string}`]: LiveHandlerArguments; +}; + +export enum ConnectionStatus { + Disconnected = "disconnected", + Connecting = "connecting", + Connected = "connected", + Error = "error", +} + +export class EngineContext { + readonly emitter: Emitter; + readonly encodeCbor: (value: unknown) => ArrayBuffer; + // biome-ignore lint/suspicious/noExplicitAny: Don't know what it will return + readonly decodeCbor: (value: ArrayBufferLike) => any; + + constructor({ + emitter, + encodeCbor, + decodeCbor, + }: { + emitter: Emitter; + encodeCbor: (value: unknown) => ArrayBuffer; + // biome-ignore lint/suspicious/noExplicitAny: Don't know what it will return + decodeCbor: (value: ArrayBufferLike) => any; + }) { + this.emitter = emitter; + this.encodeCbor = encodeCbor; + this.decodeCbor = decodeCbor; + } +} + +export abstract class AbstractEngine { + readonly context: EngineContext; + ready: Promise | undefined; + status: ConnectionStatus = ConnectionStatus.Disconnected; + connection: { + url: URL | undefined; + namespace: string | undefined; + database: string | undefined; + token: string | undefined; + } = { + url: undefined, + namespace: undefined, + database: undefined, + token: undefined, + }; + + constructor(context: EngineContext) { + this.context = context; + } + + get emitter(): EngineContext["emitter"] { + return this.context.emitter; + } + + get encodeCbor(): EngineContext["encodeCbor"] { + return this.context.encodeCbor; + } + + get decodeCbor(): EngineContext["decodeCbor"] { + return this.context.decodeCbor; + } + + abstract connect(url: URL): Promise; + abstract disconnect(): Promise; + abstract rpc< + Method extends string, + Params extends unknown[] | undefined, + Result, + >(request: RpcRequest): Promise>; + + abstract version(url: URL, timeout?: number): Promise; +} diff --git a/src/engines/http.ts b/src/engines/http.ts new file mode 100644 index 00000000..8fc01908 --- /dev/null +++ b/src/engines/http.ts @@ -0,0 +1,169 @@ +import { + ConnectionUnavailable, + HttpConnectionError, + MissingNamespaceDatabase, +} from "../errors"; +import type { RpcRequest, RpcResponse } from "../types"; +import { getIncrementalID } from "../util/getIncrementalID"; +import { retrieveRemoteVersion } from "../util/versionCheck"; +import { + AbstractEngine, + ConnectionStatus, + type EngineEvents, +} from "./abstract"; + +export class HttpEngine extends AbstractEngine { + connection: { + url: URL | undefined; + namespace: string | undefined; + database: string | undefined; + token: string | undefined; + variables: Record; + } = { + url: undefined, + namespace: undefined, + database: undefined, + token: undefined, + variables: {}, + }; + + private setStatus( + status: T, + ...args: EngineEvents[T] + ) { + this.status = status; + this.emitter.emit(status, args); + } + + version(url: URL, timeout?: number): Promise { + return retrieveRemoteVersion(url, timeout); + } + + connect(url: URL): Promise { + this.setStatus(ConnectionStatus.Connecting); + this.connection.url = url; + this.setStatus(ConnectionStatus.Connected); + this.ready = new Promise((r) => r()); + return this.ready; + } + + disconnect(): Promise { + this.connection = { + url: undefined, + namespace: undefined, + database: undefined, + token: undefined, + variables: {}, + }; + + this.ready = undefined; + this.setStatus(ConnectionStatus.Disconnected); + return new Promise((r) => r()); + } + + async rpc< + Method extends string, + Params extends unknown[] | undefined, + Result, + >(request: RpcRequest): Promise> { + await this.ready; + if (!this.connection.url) { + throw new ConnectionUnavailable(); + } + + if (request.method === "use") { + const [namespace, database] = request.params as [string, string]; + if (namespace) this.connection.namespace = namespace; + if (database) this.connection.database = database; + return { + result: true as Result, + }; + } + + if (request.method === "let") { + const [key, value] = request.params as [string, unknown]; + this.connection.variables[key] = value; + return { + result: true as Result, + }; + } + + if (request.method === "unset") { + const [key] = request.params as [string]; + delete this.connection.variables[key]; + return { + result: true as Result, + }; + } + + if (request.method === "query") { + request.params = [ + request.params?.[0], + { + ...this.connection.variables, + ...(request.params?.[1] ?? {}), + }, + ] as Params; + } + + if (!this.connection.namespace || !this.connection.database) { + throw new MissingNamespaceDatabase(); + } + + const id = getIncrementalID(); + const raw = await fetch(`${this.connection.url}`, { + method: "POST", + headers: { + "Content-Type": "application/cbor", + Accept: "application/cbor", + "Surreal-NS": this.connection.namespace, + "Surreal-DB": this.connection.database, + ...(this.connection.token + ? { Authorization: `Bearer ${this.connection.token}` } + : {}), + }, + body: this.encodeCbor({ id, ...request }), + }); + + const buffer = await raw.arrayBuffer(); + + if (raw.status === 200) { + const response: RpcResponse = this.decodeCbor(buffer); + if ("result" in response) { + switch (request.method) { + case "signin": + case "signup": { + this.connection.token = response.result as string; + break; + } + + case "authenticate": { + const [token] = request.params as [string]; + this.connection.token = token; + break; + } + + case "invalidate": { + this.connection.token = undefined; + break; + } + } + } + + this.emitter.emit(`rpc-${id}`, [response]); + return response as RpcResponse; + } + + const dec = new TextDecoder("utf-8"); + throw new HttpConnectionError( + dec.decode(buffer), + raw.status, + raw.statusText, + buffer, + ); + } + + get connected(): boolean { + return !!this.connection.url; + } +} diff --git a/src/engines/ws.ts b/src/engines/ws.ts new file mode 100644 index 00000000..98df2b50 --- /dev/null +++ b/src/engines/ws.ts @@ -0,0 +1,215 @@ +import { WebSocket } from "isows"; +import { + ConnectionUnavailable, + EngineDisconnected, + ResponseError, + UnexpectedConnectionError, + UnexpectedServerResponse, +} from "../errors"; +import { type RpcRequest, type RpcResponse, isLiveResult } from "../types"; +import { getIncrementalID } from "../util/getIncrementalID"; +import { retrieveRemoteVersion } from "../util/versionCheck"; +import { + AbstractEngine, + ConnectionStatus, + type EngineContext, + type EngineEvents, +} from "./abstract"; + +export class WebsocketEngine extends AbstractEngine { + private pinger?: Pinger; + private socket?: WebSocket; + + constructor(context: EngineContext) { + super(context); + this.emitter.subscribe("disconnected", () => this.pinger?.stop()); + } + + private setStatus( + status: T, + ...args: EngineEvents[T] + ) { + this.status = status; + this.emitter.emit(status, args); + } + + private async requireStatus( + status: T, + ): Promise { + if (this.status !== status) { + await this.emitter.subscribeOnce(status); + } + + return true; + } + + version(url: URL, timeout?: number): Promise { + return retrieveRemoteVersion(url, timeout); + } + + async connect(url: URL): Promise { + this.connection.url = url; + this.setStatus(ConnectionStatus.Connecting); + const socket = new WebSocket(url.toString(), "cbor"); + const ready = new Promise((resolve, reject) => { + socket.addEventListener("open", () => { + this.setStatus(ConnectionStatus.Connected); + resolve(); + }); + + socket.addEventListener("error", (e) => { + const error = new UnexpectedConnectionError( + "error" in e ? e.error : "An unexpected error occurred", + ); + this.setStatus(ConnectionStatus.Error, error); + reject(error); + }); + + socket.addEventListener("close", () => { + this.setStatus(ConnectionStatus.Disconnected); + }); + + socket.addEventListener("message", async ({ data }) => { + try { + const decoded = this.decodeCbor( + data instanceof Blob + ? await data.arrayBuffer() + : data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ), + ); + + if ( + typeof decoded === "object" && + decoded != null && + Object.getPrototypeOf(decoded) === Object.prototype + ) { + this.handleRpcResponse(decoded); + } else { + throw new UnexpectedServerResponse(decoded); + } + } catch (detail) { + socket.dispatchEvent(new CustomEvent("error", { detail })); + } + }); + }); + + this.ready = ready; + return await ready.then(() => { + this.socket = socket; + this.pinger?.stop(); + this.pinger = new Pinger(30000); + this.pinger.start(() => this.rpc({ method: "ping" })); + }); + } + + async disconnect(): Promise { + this.connection = { + url: undefined, + namespace: undefined, + database: undefined, + token: undefined, + }; + + await this.ready?.catch(() => {}); + this.socket?.close(); + this.ready = undefined; + this.socket = undefined; + + await Promise.any([ + this.requireStatus(ConnectionStatus.Disconnected), + this.requireStatus(ConnectionStatus.Error), + ]); + } + + async rpc< + Method extends string, + Params extends unknown[] | undefined, + Result, + >(request: RpcRequest): Promise> { + await this.ready; + if (!this.socket) throw new ConnectionUnavailable(); + + // It's not realistic for the message to ever arrive before the listener is registered on the emitter + // And we don't want to collect the response messages in the emitter + // So to be sure we simply subscribe before we send the message :) + + const id = getIncrementalID(); + const response = this.emitter.subscribeOnce(`rpc-${id}`); + this.socket.send(this.encodeCbor({ id, ...request })); + + const [res] = await response; + if (res instanceof EngineDisconnected) throw res; + + if ("result" in res) { + switch (request.method) { + case "use": { + const [ns, db] = request.params as [string, string]; + this.connection.namespace = ns; + this.connection.database = db; + break; + } + + case "signin": + case "signup": { + this.connection.token = res.result as string; + break; + } + + case "authenticate": { + const [token] = request.params as [string]; + this.connection.token = token; + break; + } + + case "invalidate": { + this.connection.token = undefined; + break; + } + } + } + + return res as RpcResponse; + } + + // biome-ignore lint/suspicious/noExplicitAny: Cannot assume type + handleRpcResponse({ id, ...res }: any): void { + if (id) { + this.emitter.emit(`rpc-${id}`, [res]); + } else if (res.error) { + this.setStatus(ConnectionStatus.Error, new ResponseError(res.error)); + } else { + if (isLiveResult(res.result)) { + const { id, action, result } = res.result; + this.emitter.emit(`live-${id}`, [action, result], true); + } else { + this.setStatus( + ConnectionStatus.Error, + new UnexpectedServerResponse({ id, ...res }), + ); + } + } + } + + get connected(): boolean { + return !!this.socket; + } +} + +export class Pinger { + private pinger?: ReturnType; + private interval: number; + + constructor(interval = 30000) { + this.interval = interval; + } + + start(callback: () => void): void { + this.pinger = setInterval(callback, this.interval); + } + + stop(): void { + clearInterval(this.pinger); + } +} diff --git a/src/errors.ts b/src/errors.ts index 55d8cbab..53e77b03 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -112,8 +112,7 @@ export class UnsupportedVersion extends SurrealDbError { super(); this.version = version; this.supportedRange = supportedRange; - this.message = - `The version "${version}" reported by the engine is not supported by this library, expected a version that satisfies "${supportedRange}".`; + this.message = `The version "${version}" reported by the engine is not supported by this library, expected a version that satisfies "${supportedRange}".`; } } @@ -121,4 +120,8 @@ export class VersionRetrievalFailure extends SurrealDbError { name = "VersionRetrievalFailure"; message = "Failed to retrieve remote version. If the server is behind a proxy, make sure it's configured correctly."; + + constructor(readonly error?: Error | undefined) { + super(); + } } diff --git a/src/index.ts b/src/index.ts index e6c5a5a6..818283c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,19 @@ -export { Surreal, Surreal as default } from "./surreal.ts"; +export { Emitter, type Listener, type UnknownEvents } from "./util/emitter.ts"; +export { surql, surrealql } from "./util/tagged-template.ts"; +export { PreparedQuery } from "./util/PreparedQuery.ts"; +export * as cbor from "./cbor"; +export * from "./cbor/gap"; +export * from "./cbor/error"; +export * from "./data"; +export * from "./errors.ts"; +export * from "./types.ts"; +export * from "./util/jsonify.ts"; +export * from "./util/versionCheck.ts"; +export * from "./util/getIncrementalID.ts"; export { ConnectionStatus, - Engine, + AbstractEngine, + type Engine, type EngineEvents, -} from "./library/engine.ts"; -export { - Emitter, - type Listener, - type UnknownEvents, -} from "./library/emitter.ts"; -export * from "./library/cbor/index.ts"; -export { surql, surrealql } from "./library/tagged-template.ts"; -export { PreparedQuery } from "./library/PreparedQuery.ts"; -export * from "./errors.ts"; -export * from "./types.ts"; -export * from "./library/jsonify.ts"; -export * from "./library/versionCheck.ts"; +} from "./engines/abstract.ts"; +export { Surreal, Surreal as default } from "./surreal.ts"; diff --git a/src/library/Pinger.ts b/src/library/Pinger.ts deleted file mode 100644 index f611cb82..00000000 --- a/src/library/Pinger.ts +++ /dev/null @@ -1,16 +0,0 @@ -export class Pinger { - private pinger?: ReturnType; - private interval: number; - - constructor(interval = 30000) { - this.interval = interval; - } - - start(callback: () => void) { - this.pinger = setInterval(callback, this.interval); - } - - stop() { - clearInterval(this.pinger); - } -} diff --git a/src/library/PreparedQuery.ts b/src/library/PreparedQuery.ts deleted file mode 100644 index ee9a72a2..00000000 --- a/src/library/PreparedQuery.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type ConvertMethod = ( - result: unknown[], -) => T; -export class PreparedQuery< - C extends ConvertMethod | undefined = ConvertMethod, -> { - public readonly query: string = ""; - public readonly bindings: Record = {}; - public readonly convert?: C; - - constructor( - query: string, - bindings?: Record, - convert?: C, - ) { - this.query = query; - this.convert = convert; - if (bindings) this.bindings = bindings; - } -} diff --git a/src/library/WebSocket/deno.ts b/src/library/WebSocket/deno.ts deleted file mode 100644 index 5b82f34d..00000000 --- a/src/library/WebSocket/deno.ts +++ /dev/null @@ -1 +0,0 @@ -export default WebSocket; diff --git a/src/library/WebSocket/node.ts b/src/library/WebSocket/node.ts deleted file mode 100644 index 4ba1478a..00000000 --- a/src/library/WebSocket/node.ts +++ /dev/null @@ -1 +0,0 @@ -export { WebSocket as default } from "npm:isows@^1.0.4"; diff --git a/src/library/cbor/decimal.ts b/src/library/cbor/decimal.ts deleted file mode 100644 index 5bb20813..00000000 --- a/src/library/cbor/decimal.ts +++ /dev/null @@ -1 +0,0 @@ -export { Decimal } from "npm:decimal.js@^10.4.3"; diff --git a/src/library/cbor/duration.ts b/src/library/cbor/duration.ts deleted file mode 100644 index f17bcbf7..00000000 --- a/src/library/cbor/duration.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Duration as IcholyDuration } from "npm:@icholy/duration@^5.1.0"; - -export class Duration extends IcholyDuration { - toJSON() { - return this.toString(); - } -} - -export function durationToCborCustomDuration(duration: Duration) { - const ms = duration.milliseconds(); - const s = Math.floor(ms / 1000); - const ns = Math.floor((ms - s * 1000) * 1000000); - return ns > 0 ? [s, ns] : s > 0 ? [s] : []; -} - -export function cborCustomDurationToDuration([s, ns]: [number, number]) { - s = s ?? 0; - ns = ns ?? 0; - const ms = (s * 1000) + (ns / 1000000); - return new Duration(ms); -} diff --git a/src/library/cbor/geometry.ts b/src/library/cbor/geometry.ts deleted file mode 100644 index 77b768c8..00000000 --- a/src/library/cbor/geometry.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { Decimal } from "npm:decimal.js@^10.4.3"; - -export abstract class Geometry { - abstract toJSON(): { - type: - | "Point" - | "LineString" - | "Polygon" - | "MultiPoint" - | "MultiLineString" - | "MultiPolygon"; - coordinates: unknown[]; - } | { - type: "GeometryCollection"; - geometries: unknown[]; - }; -} - -export class GeometryPoint extends Geometry { - readonly point: [Decimal, Decimal]; - - constructor(point: [number | Decimal, number | Decimal] | GeometryPoint) { - super(); - point = point instanceof GeometryPoint ? point.point : point; - this.point = [ - point[0] instanceof Decimal ? point[0] : new Decimal(point[0]), - point[1] instanceof Decimal ? point[1] : new Decimal(point[1]), - ]; - } - - toJSON() { - return { - type: "Point" as const, - coordinates: this.coordinates, - }; - } - - get coordinates() { - return this.point; - } -} - -export class GeometryLine extends Geometry { - readonly line: [GeometryPoint, GeometryPoint, ...GeometryPoint[]]; - - // SurrealDB only has the context of a "Line", which is two points. - // SurrealDB's "Line" is actually a "LineString" under the hood, which accepts two or more points - constructor( - line: [GeometryPoint, GeometryPoint, ...GeometryPoint[]] | GeometryLine, - ) { - super(); - line = line instanceof GeometryLine ? line.line : line; - this.line = [new GeometryPoint(line[0]), new GeometryPoint(line[1])]; - } - - toJSON() { - return { - type: "LineString" as const, - coordinates: this.coordinates, - }; - } - - get coordinates() { - return this.line.map((g) => g.coordinates); - } -} - -export class GeometryPolygon extends Geometry { - readonly polygon: [GeometryLine, ...GeometryLine[]]; - - constructor( - polygon: - | [GeometryLine, ...GeometryLine[]] - | GeometryPolygon, - ) { - super(); - polygon = polygon instanceof GeometryPolygon - ? polygon.polygon - : polygon; - this.polygon = polygon.map((line) => new GeometryLine(line)) as [ - GeometryLine, - ...GeometryLine[], - ]; - } - - toJSON() { - return { - type: "Polygon" as const, - coordinates: this.coordinates, - }; - } - - get coordinates() { - return this.polygon.map((g) => g.coordinates); - } -} - -export class GeometryMultiPoint extends Geometry { - readonly points: [GeometryPoint, ...GeometryPoint[]]; - - constructor( - points: [GeometryPoint, ...GeometryPoint[]] | GeometryMultiPoint, - ) { - super(); - points = points instanceof GeometryMultiPoint ? points.points : points; - this.points = points.map((point) => new GeometryPoint(point)) as [ - GeometryPoint, - ...GeometryPoint[], - ]; - } - - toJSON() { - return { - type: "MultiPoint" as const, - coordinates: this.coordinates, - }; - } - - get coordinates() { - return this.points.map((g) => g.coordinates); - } -} - -export class GeometryMultiLine extends Geometry { - readonly lines: [GeometryLine, ...GeometryLine[]]; - - constructor(lines: [GeometryLine, ...GeometryLine[]] | GeometryMultiLine) { - super(); - lines = lines instanceof GeometryMultiLine ? lines.lines : lines; - this.lines = lines.map((line) => new GeometryLine(line)) as [ - GeometryLine, - ...GeometryLine[], - ]; - } - - toJSON() { - return { - type: "MultiLineString" as const, - coordinates: this.coordinates, - }; - } - - get coordinates() { - return this.lines.map((g) => g.coordinates); - } -} - -export class GeometryMultiPolygon extends Geometry { - readonly polygons: [GeometryPolygon, ...GeometryPolygon[]]; - - constructor( - polygons: - | [GeometryPolygon, ...GeometryPolygon[]] - | GeometryMultiPolygon, - ) { - super(); - polygons = polygons instanceof GeometryMultiPolygon - ? polygons.polygons - : polygons; - - this.polygons = polygons.map( - (polygon) => new GeometryPolygon(polygon), - ) as [GeometryPolygon, ...GeometryPolygon[]]; - } - - toJSON() { - return { - type: "MultiPolygon" as const, - coordinates: this.coordinates, - }; - } - - get coordinates() { - return this.polygons.map((g) => g.coordinates); - } -} - -export class GeometryCollection - extends Geometry { - readonly collection: T; - - constructor(collection: T | GeometryCollection) { - super(); - collection = collection instanceof GeometryCollection - ? collection.collection - : collection; - this.collection = collection; - } - - toJSON() { - return { - type: "GeometryCollection" as const, - geometries: this.geometries, - }; - } - - get geometries() { - return this.collection.map((g) => g.toJSON()) as { - [K in keyof T]: ReturnType; - }; - } -} diff --git a/src/library/cbor/recordid.ts b/src/library/cbor/recordid.ts deleted file mode 100644 index 4b655864..00000000 --- a/src/library/cbor/recordid.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { z } from "npm:zod"; - -export const RecordIdValue = z.union([ - z.string(), - z.number(), - z.bigint(), - z.record(z.unknown()), - z.array(z.unknown()), -]); - -export type RecordIdValue = z.infer; - -export class RecordId { - public readonly tb: Tb; - public readonly id: RecordIdValue; - - constructor(tb: Tb, id: RecordIdValue) { - this.tb = z.string().parse(tb) as Tb; - this.id = RecordIdValue.parse(id); - } - - toJSON() { - return this.toString(); - } - - toString() { - const tb = escape_ident(this.tb); - const id = typeof this.id == "string" - ? escape_ident(this.id) - : JSON.stringify(this.id); - return `${tb}:${id}`; - } -} - -export class StringRecordId { - public readonly rid: string; - - constructor(rid: string) { - this.rid = z.string().parse(rid); - } - - toJSON() { - return this.rid; - } - - toString() { - return this.rid; - } -} - -function escape_ident(str: string) { - // String which looks like a number should always be escaped, to prevent it from being parsed as a number - if (isOnlyNumbers(str)) { - return `⟨${str}⟩`; - } - - let code, i, len; - - for (i = 0, len = str.length; i < len; i++) { - code = str.charCodeAt(i); - if ( - !(code > 47 && code < 58) && // numeric (0-9) - !(code > 64 && code < 91) && // upper alpha (A-Z) - !(code > 96 && code < 123) && // lower alpha (a-z) - !(code == 95) // underscore (_) - ) { - return `⟨${str.replaceAll("⟩", "\⟩")}⟩`; - } - } - - return str; -} - -function isOnlyNumbers(str: string) { - const parsed = parseInt(str); - return !isNaN(parsed) && parsed.toString() === str; -} diff --git a/src/library/cbor/uuid.ts b/src/library/cbor/uuid.ts deleted file mode 100644 index fb7699a0..00000000 --- a/src/library/cbor/uuid.ts +++ /dev/null @@ -1 +0,0 @@ -export { UUID, uuidv4, uuidv7 } from "npm:uuidv7@0.6.3"; diff --git a/src/library/engine.ts b/src/library/engine.ts deleted file mode 100644 index ad529278..00000000 --- a/src/library/engine.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { z } from "npm:zod"; -import { Emitter } from "./emitter.ts"; -import { getIncrementalID } from "./getIncrementalID.ts"; -import WebSocket from "./WebSocket/deno.ts"; -import { decodeCbor, encodeCbor } from "./cbor/index.ts"; -import { - Action, - LiveResult, - Patch, - RpcRequest, - RpcResponse, -} from "../types.ts"; -import { - ConnectionUnavailable, - EngineDisconnected, - HttpConnectionError, - MissingNamespaceDatabase, - ResponseError, - UnexpectedConnectionError, - UnexpectedServerResponse, -} from "../errors.ts"; -import { retrieveRemoteVersion } from "./versionCheck.ts"; - -export type EngineEvents = { - connecting: []; - connected: []; - disconnected: []; - error: [Error]; - - [K: `rpc-${string | number}`]: [RpcResponse | EngineDisconnected]; - [K: `live-${string}`]: [Action, Record | Patch]; -}; - -export enum ConnectionStatus { - Disconnected = "disconnected", - Connecting = "connecting", - Connected = "connected", - Error = "error", -} - -export abstract class Engine { - constructor(...[_]: [emitter: Emitter]) {} - abstract emitter: Emitter; - abstract ready: Promise | undefined; - abstract status: ConnectionStatus; - abstract connect(url: URL): Promise; - abstract disconnect(): Promise; - abstract rpc< - Method extends string, - Params extends unknown[] | undefined, - Result extends unknown, - >(request: RpcRequest): Promise>; - abstract connection: { - url?: URL; - namespace?: string; - database?: string; - token?: string; - }; - - abstract version(url: URL, timeout: number): Promise; -} - -export class WebsocketEngine implements Engine { - ready: Promise | undefined = undefined; - status: ConnectionStatus = ConnectionStatus.Disconnected; - connection: { - url?: URL; - namespace?: string; - database?: string; - token?: string; - } = {}; - - readonly emitter: Emitter; - private socket?: WebSocket; - - constructor(emitter: Emitter) { - this.emitter = emitter; - } - - private setStatus( - status: T, - ...args: EngineEvents[T] - ) { - this.status = status; - this.emitter.emit(status, args); - } - - private async requireStatus( - status: T, - ): Promise { - if (this.status != status) { - await this.emitter.subscribeOnce(status); - } - - return true; - } - - version(url: URL, timeout: number): Promise { - return retrieveRemoteVersion(url, timeout); - } - - async connect(url: URL) { - this.connection.url = url; - this.setStatus(ConnectionStatus.Connecting); - const socket = new WebSocket(url.toString(), "cbor"); - const ready = new Promise((resolve, reject) => { - socket.addEventListener("open", () => { - this.setStatus(ConnectionStatus.Connected); - resolve(); - }); - - socket.addEventListener("error", (e) => { - const error = new UnexpectedConnectionError( - "error" in e ? e.error : "An unexpected error occurred", - ); - this.setStatus(ConnectionStatus.Error, error); - reject(error); - }); - - socket.addEventListener("close", () => { - this.setStatus(ConnectionStatus.Disconnected); - }); - - socket.addEventListener("message", async ({ data }) => { - const decoded = decodeCbor( - data instanceof Blob - ? await data.arrayBuffer() - : data.buffer.slice( - data.byteOffset, - data.byteOffset + data.byteLength, - ), - ); - - if ( - typeof decoded == "object" && !Array.isArray(decoded) && - decoded != null - ) { - this.handleRpcResponse(decoded); - } else { - this.setStatus( - ConnectionStatus.Error, - new UnexpectedServerResponse(decoded), - ); - } - }); - }); - - this.ready = ready; - return await ready.then(() => { - this.socket = socket; - }); - } - - async disconnect(): Promise { - this.connection = {}; - await this.ready?.catch(() => {}); - this.socket?.close(); - this.ready = undefined; - this.socket = undefined; - - await Promise.any([ - this.requireStatus(ConnectionStatus.Disconnected), - this.requireStatus(ConnectionStatus.Error), - ]); - } - - async rpc< - Method extends string, - Params extends unknown[] | undefined, - Result extends unknown, - >(request: RpcRequest): Promise> { - await this.ready; - if (!this.socket) throw new ConnectionUnavailable(); - - // It's not realistic for the message to ever arrive before the listener is registered on the emitter - // And we don't want to collect the response messages in the emitter - // So to be sure we simply subscribe before we send the message :) - - const id = getIncrementalID(); - const response = this.emitter.subscribeOnce(`rpc-${id}`); - this.socket.send(encodeCbor({ id, ...request })); - return response.then(([res]) => { - if (res instanceof EngineDisconnected) throw res; - - if ("result" in res) { - switch (request.method) { - case "use": { - this.connection.namespace = z.string().parse( - request.params?.[0], - ); - this.connection.database = z.string().parse( - request.params?.[1], - ); - break; - } - - case "signin": - case "signup": { - this.connection.token = res.result as string; - break; - } - - case "authenticate": { - this.connection.token = request.params - ?.[0] as string; - break; - } - - case "invalidate": { - delete this.connection.token; - break; - } - } - } - - return res as RpcResponse; - }); - } - - // deno-lint-ignore no-explicit-any - handleRpcResponse({ id, ...res }: any) { - if (id) { - this.emitter.emit(`rpc-${id}`, [res]); - } else if (res.error) { - this.setStatus( - ConnectionStatus.Error, - new ResponseError(res.error), - ); - } else { - const live = LiveResult.safeParse(res.result); - if (live.success) { - const { id, action, result } = live.data; - this.emitter.emit(`live-${id}`, [action, result], true); - } else { - this.setStatus( - ConnectionStatus.Error, - new UnexpectedServerResponse({ id, ...res }), - ); - } - } - } - - get connected() { - return !!this.socket; - } -} - -export class HttpEngine implements Engine { - ready: Promise | undefined = undefined; - status: ConnectionStatus = ConnectionStatus.Disconnected; - readonly emitter: Emitter; - connection: { - url?: URL; - namespace?: string; - database?: string; - token?: string; - variables: Record; - } = { variables: {} }; - - constructor(emitter: Emitter) { - this.emitter = emitter; - } - - private setStatus( - status: T, - ...args: EngineEvents[T] - ) { - this.status = status; - this.emitter.emit(status, args); - } - - version(url: URL, timeout: number): Promise { - return retrieveRemoteVersion(url, timeout); - } - - connect(url: URL) { - this.setStatus(ConnectionStatus.Connecting); - this.connection.url = url; - this.setStatus(ConnectionStatus.Connected); - this.ready = new Promise((r) => r()); - return this.ready; - } - - disconnect(): Promise { - this.connection = { variables: {} }; - this.ready = undefined; - this.setStatus(ConnectionStatus.Disconnected); - return new Promise((r) => r()); - } - - async rpc< - Method extends string, - Params extends unknown[] | undefined, - Result extends unknown, - >(request: RpcRequest): Promise> { - await this.ready; - if (!this.connection.url) { - throw new ConnectionUnavailable(); - } - - if (request.method == "use") { - const [namespace, database] = z.tuple([z.string(), z.string()]) - .parse(request.params); - if (namespace) this.connection.namespace = namespace; - if (database) this.connection.database = database; - return { - result: true as Result, - }; - } - - if (request.method == "let") { - const [key, value] = z.tuple([z.string(), z.unknown()]).parse( - request.params, - ); - this.connection.variables[key] = value; - return { - result: true as Result, - }; - } - - if (request.method == "unset") { - const [key] = z.tuple([z.string()]).parse(request.params); - delete this.connection.variables[key]; - return { - result: true as Result, - }; - } - - if (request.method == "query") { - request.params = [ - request.params?.[0], - { - ...this.connection.variables, - ...(request.params?.[1] ?? {}), - }, - ] as Params; - } - - if (!this.connection.namespace || !this.connection.database) { - throw new MissingNamespaceDatabase(); - } - - const id = getIncrementalID(); - const raw = await fetch(`${this.connection.url}`, { - method: "POST", - headers: { - "Content-Type": "application/cbor", - Accept: "application/cbor", - "Surreal-NS": this.connection.namespace, - "Surreal-DB": this.connection.database, - ...(this.connection.token - ? { Authorization: `Bearer ${this.connection.token}` } - : {}), - }, - body: encodeCbor({ id, ...request }), - }); - - const buffer = await raw.arrayBuffer(); - - if (raw.status == 200) { - const response: RpcResponse = decodeCbor(buffer); - if ("result" in response) { - switch (request.method) { - case "signin": - case "signup": { - this.connection.token = response.result as string; - break; - } - - case "authenticate": { - this.connection.token = request.params?.[0] as string; - break; - } - - case "invalidate": { - delete this.connection.token; - break; - } - } - } - - this.emitter.emit(`rpc-${id}`, [response]); - return response as RpcResponse; - } else { - const dec = new TextDecoder("utf-8"); - throw new HttpConnectionError( - dec.decode(buffer), - raw.status, - raw.statusText, - buffer, - ); - } - } - - get connected() { - return !!this.connection.url; - } -} diff --git a/src/library/getIncrementalID.ts b/src/library/getIncrementalID.ts deleted file mode 100644 index c44ec045..00000000 --- a/src/library/getIncrementalID.ts +++ /dev/null @@ -1,4 +0,0 @@ -let id = 0; -export function getIncrementalID() { - return (id = (id + 1) % Number.MAX_SAFE_INTEGER).toString(); -} diff --git a/src/library/isNil.ts b/src/library/isNil.ts deleted file mode 100644 index e8e94ade..00000000 --- a/src/library/isNil.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isNil(v: unknown): v is undefined | null { - return v === undefined || v === null; -} diff --git a/src/library/jsonify.ts b/src/library/jsonify.ts deleted file mode 100644 index f6aa977b..00000000 --- a/src/library/jsonify.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Decimal } from "./cbor/decimal.ts"; -import { Geometry } from "./cbor/geometry.ts"; -import { Duration } from "./cbor/index.ts"; -import { RecordId, StringRecordId } from "./cbor/recordid.ts"; -import { Table } from "./cbor/table.ts"; -import { UUID } from "./cbor/uuid.ts"; - -export type Jsonify = T extends - Date | UUID | Decimal | Duration | StringRecordId ? string - : T extends undefined ? never - : T extends Record | Array - ? { [K in keyof T]: Jsonify } - : T extends Geometry ? ReturnType - : T extends RecordId ? `${Tb}:${string}` - : T extends Table ? `${Tb}` - : T; - -export function jsonify(input: T): Jsonify { - return JSON.parse(JSON.stringify(input)); -} diff --git a/src/library/processAuthVars.ts b/src/library/processAuthVars.ts deleted file mode 100644 index 9697df7a..00000000 --- a/src/library/processAuthVars.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NoDatabaseSpecified, NoNamespaceSpecified } from "../errors.ts"; -import { AnyAuth } from "../types.ts"; -import { isNil } from "./isNil.ts"; - -export function processAuthVars(vars: T, fallback?: { - namespace?: string; - database?: string; -}) { - if ("scope" in vars) { - if (!vars.namespace) vars.namespace = fallback?.namespace; - if (!vars.database) vars.database = fallback?.database; - if (isNil(vars.namespace)) { - throw new NoNamespaceSpecified(); - } - if (isNil(vars.database)) throw new NoDatabaseSpecified(); - } - - return vars; -} diff --git a/src/library/versionCheck.ts b/src/library/versionCheck.ts deleted file mode 100644 index 43d64bb2..00000000 --- a/src/library/versionCheck.ts +++ /dev/null @@ -1,54 +0,0 @@ -import semver from "npm:semver"; -import { UnsupportedVersion, VersionRetrievalFailure } from "../errors.ts"; - -export const supportedSurrealDbVersionRange = ">= 1.4.2 < 2.0.0"; - -export function versionCheck(version: string): true { - if (!isVersionSupported(version)) { - throw new UnsupportedVersion(version, supportedSurrealDbVersionRange); - } - - return true; -} - -export function isVersionSupported(version: string) { - return semver.satisfies(version, supportedSurrealDbVersionRange); -} - -export async function retrieveRemoteVersion(url: URL, timeout: number) { - const mappedProtocols = { - "ws:": "http:", - "wss:": "https:", - "http:": "http:", - "https:": "https:", - } as Record; - - const protocol = mappedProtocols[url.protocol]; - if (protocol) { - url = new URL(url); - url.protocol = protocol; - url.pathname = url.pathname.slice(0, -4) + "/version"; - - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), timeout); - const versionPrefix = "surrealdb-"; - const version = await fetch( - url, - { - signal: controller.signal, - }, - ) - .then((res) => res.text()) - .then((version) => version.slice(versionPrefix.length)) - .catch(() => { - throw new VersionRetrievalFailure(); - }) - .finally(() => { - clearTimeout(id); - }); - - return version; - } - - throw new VersionRetrievalFailure(); -} diff --git a/src/surreal.ts b/src/surreal.ts index ed412be5..450850f8 100644 --- a/src/surreal.ts +++ b/src/surreal.ts @@ -1,3 +1,41 @@ +import { + type StringRecordId, + type Table, + type Uuid, + type RecordId as _RecordId, + decodeCbor, + encodeCbor, +} from "./data"; +import { + type AbstractEngine, + ConnectionStatus, + EngineContext, + type EngineEvents, + type Engines, +} from "./engines/abstract.ts"; +import { PreparedQuery } from "./util/PreparedQuery.ts"; +import { Emitter } from "./util/emitter.ts"; +import { processAuthVars } from "./util/processAuthVars.ts"; +import { versionCheck } from "./util/versionCheck.ts"; + +import { + type AccessAuth, + type ActionResult, + type AnyAuth, + type LiveHandler, + type MapQueryResult, + type Patch, + type Prettify, + type QueryParameters, + type RpcResponse, + type ScopeAuth, + type Token, + convertAuth, +} from "./types.ts"; + +import { type Fill, partiallyEncodeObject } from "./cbor"; +import { HttpEngine } from "./engines/http.ts"; +import { WebsocketEngine } from "./engines/ws.ts"; import { EngineDisconnected, NoActiveSocket, @@ -7,41 +45,12 @@ import { ResponseError, UnsupportedEngine, } from "./errors.ts"; -import { PreparedQuery } from "./library/PreparedQuery.ts"; -import { Pinger } from "./library/Pinger.ts"; -import { EngineEvents } from "./library/engine.ts"; -import { Engine, HttpEngine, WebsocketEngine } from "./library/engine.ts"; -import { - RecordId as _RecordId, - StringRecordId, -} from "./library/cbor/recordid.ts"; -import { Emitter } from "./library/emitter.ts"; -import { processAuthVars } from "./library/processAuthVars.ts"; -import type { UUID } from "./library/cbor/uuid.ts"; -import { - Action, - type ActionResult, - AnyAuth, - type ConnectionOptions, - LiveHandler, - type MapQueryResult, - type Patch, - Prettify, - processConnectionOptions, - ScopeAuth, - Token, - TransformAuth, -} from "./types.ts"; -import { ConnectionStatus } from "./library/engine.ts"; -import { versionCheck } from "./library/versionCheck.ts"; -type Engines = Record) => Engine>; type R = Prettify>; type RecordId = _RecordId | StringRecordId; export class Surreal { - public connection: Engine | undefined; - private pinger?: Pinger; + public connection: AbstractEngine | undefined; ready?: Promise; emitter: Emitter; protected engines: Engines = { @@ -57,10 +66,7 @@ export class Surreal { engines?: Engines; } = {}) { this.emitter = new Emitter(); - this.emitter.subscribe( - ConnectionStatus.Disconnected, - () => this.clean(), - ); + this.emitter.subscribe(ConnectionStatus.Disconnected, () => this.clean()); this.emitter.subscribe(ConnectionStatus.Error, () => this.close()); if (engines) { @@ -75,7 +81,18 @@ export class Surreal { * Establish a socket connection to the database * @param connection - Connection details */ - async connect(url: string | URL, opts: ConnectionOptions = {}) { + async connect( + url: string | URL, + opts: { + namespace?: string; + database?: string; + auth?: AnyAuth | Token; + prepare?: (connection: Surreal) => unknown; + versionCheck?: boolean; + versionCheckTimeout?: number; + } = {}, + ): Promise { + // biome-ignore lint/style/noParameterAssign: Need to ensure it's a URL url = new URL(url); if (!url.pathname.endsWith("/rpc")) { @@ -87,32 +104,38 @@ export class Surreal { const engine = this.engines[engineName]; if (!engine) throw new UnsupportedEngine(engineName); - const { prepare, auth, namespace, database } = processConnectionOptions( - opts, - ); + // Process options + const { prepare, auth, namespace, database } = opts; + if (opts.namespace || opts.database) { + if (!opts.namespace) throw new NoNamespaceSpecified(); + if (!opts.database) throw new NoDatabaseSpecified(); + } // Close any existing connections await this.close(); + // We need to pass CBOR encoding and decoding functions as an argument to engines, + // to ensure that everything is using the same instance of classes that these methods depend on. + const context = new EngineContext({ + emitter: this.emitter, + encodeCbor, + decodeCbor, + }); + // The promise does not know if `this.connection` is undefined or not, but it does about `connection` - const connection = new engine(this.emitter); + const connection = new engine(context); // If not disabled, run a version check if (opts.versionCheck !== false) { - const version = await connection.version( - url, - opts.versionCheckTimeout ?? 5000, - ); + const version = await connection.version(url, opts.versionCheckTimeout); versionCheck(version); } this.connection = connection; - this.pinger = new Pinger(30000); - this.ready = new Promise((resolve, reject) => - connection.connect(url as URL) + connection + .connect(url as URL) .then(async () => { - this.pinger?.start(() => this.ping()); if (namespace || database) { await this.use({ namespace, @@ -129,49 +152,54 @@ export class Surreal { await prepare?.(this); resolve(); }) - .catch(reject) + .catch(reject), ); - return this.ready; + await this.ready; + return true; } /** * Disconnect the socket to the database */ - async close() { + async close(): Promise { this.clean(); await this.connection?.disconnect(); + return true; } private clean() { - this.pinger?.stop(); - // Scan all pending rpc requests const pending = this.emitter.scanListeners((k) => k.startsWith("rpc-")); - // Ensure all rpc requests get a connection closed response pending.map((k) => this.emitter.emit(k, [new EngineDisconnected()])); + // Scan all active live listeners + const live = this.emitter.scanListeners((k) => k.startsWith("live-")); + // Ensure all live listeners get a CLOSE message with disconnected as reason + live.map((k) => this.emitter.emit(k, ["CLOSE", "disconnected"])); + // Cleanup subscriptions and yet to be collected emisions this.emitter.reset({ collectable: true, - listeners: pending, + listeners: [...pending, ...live], }); } /** * Check if connection is ready */ - get status() { - return this.connection?.status; + get status(): ConnectionStatus { + return this.connection?.status ?? ConnectionStatus.Disconnected; } /** * Ping SurrealDB instance */ - async ping() { + async ping(): Promise { const { error } = await this.rpc("ping"); if (error) throw new ResponseError(error.message); + return true; } /** @@ -185,7 +213,7 @@ export class Surreal { }: { namespace?: string; database?: string; - }) { + }): Promise { if (!this.connection) throw new NoActiveSocket(); if (!namespace && !this.connection.connection.namespace) { @@ -201,6 +229,7 @@ export class Surreal { ]); if (error) throw new ResponseError(error.message); + return true; } /** @@ -223,15 +252,13 @@ export class Surreal { * @param vars - Variables used in a signup query. * @return The authentication token. */ - async signup(vars: ScopeAuth) { + async signup(vars: ScopeAuth | AccessAuth): Promise { if (!this.connection) throw new NoActiveSocket(); - vars = ScopeAuth.parse(vars); - vars = processAuthVars(vars, this.connection.connection); + const parsed = processAuthVars(vars, this.connection.connection); + const converted = convertAuth(parsed); + const res = await this.rpc("signup", [converted]); - const res = await this.rpc("signup", [ - TransformAuth.parse(vars), - ]); if (res.error) throw new ResponseError(res.error.message); if (!res.result) { throw new NoTokenReturned(); @@ -245,15 +272,13 @@ export class Surreal { * @param vars - Variables used in a signin query. * @return The authentication token. */ - async signin(vars: AnyAuth) { + async signin(vars: AnyAuth): Promise { if (!this.connection) throw new NoActiveSocket(); - vars = AnyAuth.parse(vars); - vars = processAuthVars(vars, this.connection.connection); + const parsed = processAuthVars(vars, this.connection.connection); + const converted = convertAuth(parsed); + const res = await this.rpc("signin", [converted]); - const res = await this.rpc("signin", [ - TransformAuth.parse(vars), - ]); if (res.error) throw new ResponseError(res.error.message); if (!res.result) { throw new NoTokenReturned(); @@ -266,10 +291,8 @@ export class Surreal { * Authenticates the current connection with a JWT token. * @param token - The JWT authentication token. */ - async authenticate(token: Token) { - const res = await this.rpc("authenticate", [ - Token.parse(token), - ]); + async authenticate(token: Token): Promise { + const res = await this.rpc("authenticate", [token]); if (res.error) throw new ResponseError(res.error.message); return true; } @@ -277,7 +300,7 @@ export class Surreal { /** * Invalidates the authentication for the current connection. */ - async invalidate() { + async invalidate(): Promise { const res = await this.rpc("invalidate"); if (res.error) throw new ResponseError(res.error.message); return true; @@ -288,7 +311,7 @@ export class Surreal { * @param key - Specifies the name of the variable. * @param val - Assigns the value to the variable name. */ - async let(variable: string, value: unknown) { + async let(variable: string, value: unknown): Promise { const res = await this.rpc("let", [variable, value]); if (res.error) throw new ResponseError(res.error.message); return true; @@ -298,9 +321,10 @@ export class Surreal { * Remove a variable from the current socket connection. * @param key - Specifies the name of the variable. */ - async unset(variable: string) { + async unset(variable: string): Promise { const res = await this.rpc("unset", [variable]); if (res.error) throw new ResponseError(res.error.message); + return true; } /** @@ -310,13 +334,14 @@ export class Surreal { * @param diff - If set to true, will return a set of patches instead of complete records */ async live< - Result extends Record | Patch = Record< - string, - unknown - >, - >(table: string, callback?: LiveHandler, diff?: boolean) { + Result extends Record | Patch = Record, + >( + table: string, + callback?: LiveHandler, + diff?: boolean, + ): Promise { await this.ready; - const res = await this.rpc("live", [table, diff]); + const res = await this.rpc("live", [table, diff]); if (res.error) throw new ResponseError(res.error.message); if (callback) this.subscribeLive(res.result, callback); @@ -330,19 +355,13 @@ export class Surreal { * @param callback - Callback function that receives updates. */ async subscribeLive< - Result extends Record | Patch = Record< - string, - unknown - >, - >(queryUuid: UUID, callback: LiveHandler) { + Result extends Record | Patch = Record, + >(queryUuid: Uuid, callback: LiveHandler): Promise { await this.ready; if (!this.connection) throw new NoActiveSocket(); this.connection.emitter.subscribe( `live-${queryUuid}`, - callback as ( - action: Action, - result: Record | Patch, - ) => unknown, + callback as LiveHandler, true, ); } @@ -353,19 +372,13 @@ export class Surreal { * @param callback - Callback function that receives updates. */ async unSubscribeLive< - Result extends Record | Patch = Record< - string, - unknown - >, - >(queryUuid: UUID, callback: LiveHandler) { + Result extends Record | Patch = Record, + >(queryUuid: Uuid, callback: LiveHandler): Promise { await this.ready; if (!this.connection) throw new NoActiveSocket(); this.connection.emitter.unSubscribe( `live-${queryUuid}`, - callback as ( - action: Action, - result: Record | Patch, - ) => unknown, + callback as LiveHandler, ); } @@ -373,18 +386,20 @@ export class Surreal { * Kill a live query * @param queryUuid - The query that you want to kill. */ - async kill(queryUuid: UUID | readonly UUID[]) { + async kill(queryUuid: Uuid | readonly Uuid[]): Promise { await this.ready; if (!this.connection) throw new NoActiveSocket(); if (Array.isArray(queryUuid)) { await Promise.all(queryUuid.map((u) => this.rpc("kill", [u]))); const toBeKilled = queryUuid.map((u) => `live-${u}` as const); + toBeKilled.map((k) => this.emitter.emit(k, ["CLOSE", "killed"])); this.connection.emitter.reset({ collectable: toBeKilled, listeners: toBeKilled, }); } else { await this.rpc("kill", [queryUuid]); + this.emitter.emit(`live-${queryUuid}`, ["CLOSE", "killed"]); this.connection.emitter.reset({ collectable: `live-${queryUuid}`, listeners: `live-${queryUuid}`, @@ -398,12 +413,11 @@ export class Surreal { * @param bindings - Assigns variables which can be used in the query. */ async query( - query: string | PreparedQuery, - bindings?: Record, - ) { - const raw = await this.query_raw(query, bindings); + ...args: QueryParameters + ): Promise> { + const raw = await this.query_raw(...args); return raw.map(({ status, result }) => { - if (status == "ERR") throw new ResponseError(result); + if (status === "ERR") throw new ResponseError(result); return result; }) as T; } @@ -414,20 +428,15 @@ export class Surreal { * @param bindings - Assigns variables which can be used in the query. */ async query_raw( - query: string | PreparedQuery, - bindings?: Record, - ) { - if (typeof query !== "string") { - bindings = bindings ?? {}; - bindings = { ...bindings, ...query.bindings }; - query = query.query; - } + ...[q, b]: QueryParameters + ): Promise>> { + const params = + q instanceof PreparedQuery + ? [q.query, partiallyEncodeObject(q.bindings, b as Fill[])] + : [q, b]; await this.ready; - const res = await this.rpc>("query", [ - query, - bindings, - ]); + const res = await this.rpc>("query", params); if (res.error) throw new ResponseError(res.error.message); return res.result; } @@ -436,9 +445,9 @@ export class Surreal { * Selects all records in a table, or a specific record, from the database. * @param thing - The table name or a record ID to select. */ - async select(thing: string): Promise[]>; + async select(thing: Table | string): Promise[]>; async select(thing: RecordId): Promise>; - async select(thing: RecordId | string) { + async select(thing: RecordId | Table | string) { await this.ready; const res = await this.rpc>("select", [thing]); if (res.error) throw new ResponseError(res.error.message); @@ -451,7 +460,7 @@ export class Surreal { * @param data - The document / record data to insert. */ async create( - thing: string, + thing: Table | string, data?: U, ): Promise[]>; async create( @@ -459,14 +468,11 @@ export class Surreal { data?: U, ): Promise>; async create( - thing: RecordId | string, + thing: RecordId | Table | string, data?: U, ) { await this.ready; - const res = await this.rpc>("create", [ - thing, - data, - ]); + const res = await this.rpc>("create", [thing, data]); if (res.error) throw new ResponseError(res.error.message); return res.result; } @@ -477,7 +483,7 @@ export class Surreal { * @param data - The document(s) / record(s) to insert. */ async insert( - thing: string, + thing: Table | string, data?: U | U[], ): Promise[]>; async insert( @@ -485,14 +491,11 @@ export class Surreal { data?: U, ): Promise>; async insert( - thing: RecordId | string, + thing: RecordId | Table | string, data?: U | U[], ) { await this.ready; - const res = await this.rpc>("insert", [ - thing, - data, - ]); + const res = await this.rpc>("insert", [thing, data]); if (res.error) throw new ResponseError(res.error.message); return res.result; } @@ -505,7 +508,7 @@ export class Surreal { * @param data - The document / record data to insert. */ async update( - thing: string, + thing: Table | string, data?: U, ): Promise[]>; async update( @@ -513,14 +516,11 @@ export class Surreal { data?: U, ): Promise>; async update( - thing: RecordId | string, + thing: RecordId | Table | string, data?: U, ) { await this.ready; - const res = await this.rpc>("update", [ - thing, - data, - ]); + const res = await this.rpc>("update", [thing, data]); if (res.error) throw new ResponseError(res.error.message); return res.result; } @@ -533,7 +533,7 @@ export class Surreal { * @param data - The document / record data to insert. */ async merge>( - thing: string, + thing: Table | string, data?: U, ): Promise[]>; async merge>( @@ -541,14 +541,11 @@ export class Surreal { data?: U, ): Promise>; async merge>( - thing: RecordId | string, + thing: RecordId | Table | string, data?: U, ) { await this.ready; - const res = await this.rpc>("merge", [ - thing, - data, - ]); + const res = await this.rpc>("merge", [thing, data]); if (res.error) throw new ResponseError(res.error.message); return res.result; } @@ -566,7 +563,7 @@ export class Surreal { diff?: false, ): Promise>; async patch( - thing: string, + thing: Table | Table | string, data?: Patch[], diff?: false, ): Promise[]>; @@ -576,14 +573,20 @@ export class Surreal { diff: true, ): Promise; async patch( - thing: string, + thing: Table | Table | string, data: undefined | Patch[], diff: true, ): Promise; - async patch(thing: RecordId | string, data?: Patch[], diff?: boolean) { + async patch( + thing: RecordId | Table | Table | string, + data?: Patch[], + diff?: boolean, + ) { await this.ready; - // deno-lint-ignore no-explicit-any + + // biome-ignore lint/suspicious/noExplicitAny: Cannot assume type here due to function overload const res = await this.rpc("patch", [thing, data, diff]); + if (res.error) throw new ResponseError(res.error.message); return res.result; } @@ -592,9 +595,9 @@ export class Surreal { * Deletes all records in a table, or a specific record, from the database. * @param thing - The table name or a record ID to select. */ - async delete(thing: string): Promise[]>; + async delete(thing: Table | string): Promise[]>; async delete(thing: RecordId): Promise>; - async delete(thing: RecordId | string) { + async delete(thing: RecordId | Table | string) { await this.ready; const res = await this.rpc>("delete", [thing]); if (res.error) throw new ResponseError(res.error.message); @@ -616,23 +619,20 @@ export class Surreal { * @param name - The full name of the function * @param args - The arguments supplied to the function. You can also supply a version here as a string, in which case the third argument becomes the parameter list. */ - async run(name: string, args?: unknown[]): Promise; + async run(name: string, args?: unknown[]): Promise; /** * Run a SurrealQL function * @param name - The full name of the function * @param version - The version of the function. If omitted, the second argument is the parameter list. * @param args - The arguments supplied to the function. */ - async run( - name: string, - version: string, - args?: unknown[], - ): Promise; + async run(name: string, version: string, args?: unknown[]): Promise; async run(name: string, arg2?: string | unknown[], arg3?: unknown[]) { await this.ready; const [version, args] = Array.isArray(arg2) ? [undefined, arg2] : [arg2, arg3]; + const res = await this.rpc("run", [name, version, args]); if (res.error) throw new ResponseError(res.error.message); return res.result; @@ -674,7 +674,10 @@ export class Surreal { * @param method - Type of message to send. * @param params - Parameters for the message. */ - protected rpc(method: string, params?: unknown[]) { + protected rpc( + method: string, + params?: unknown[], + ): Promise> { if (!this.connection) throw new NoActiveSocket(); return this.connection.rpc({ method, diff --git a/src/types.ts b/src/types.ts index 0a7a3459..8f82ad0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,122 +1,92 @@ -import { z } from "npm:zod"; -import { RecordId } from "./library/cbor/recordid.ts"; -import { Surreal } from "./surreal.ts"; -import { UUID } from "./library/cbor/uuid.ts"; +import type { Encoded, Fill } from "./cbor"; +import { type RecordId, Uuid } from "./data"; +import type { PreparedQuery } from "./util/PreparedQuery"; -export const UseOptions = z.object({ - namespace: z.coerce.string(), - database: z.coerce.string(), -}); +export type ActionResult> = Prettify< + T["id"] extends RecordId ? T : { id: RecordId } & T +>; -export type UseOptions = z.infer; +export type Prettify = { + [K in keyof T]: T[K]; +} & {}; // deno-lint-ignore ban-types -export type ActionResult< - T extends Record, -> = Prettify; - -export type Prettify = - & { - [K in keyof T]: T[K]; - } - // deno-lint-ignore ban-types - & {}; +export type QueryParameters = + | [query: string, bindings?: Record] + | [prepared: PreparedQuery, gaps?: Fill[]]; ////////////////////////////////////////////// ////////// AUTHENTICATION TYPES ////////// ////////////////////////////////////////////// -export const SuperUserAuth = z.object({ - namespace: z.never().optional(), - database: z.never().optional(), - scope: z.never().optional(), - username: z.coerce.string(), - password: z.coerce.string(), -}); - -export type SuperUserAuth = z.infer; - -export const NamespaceAuth = z.object({ - namespace: z.coerce.string(), - database: z.never().optional(), - scope: z.never().optional(), - username: z.coerce.string(), - password: z.coerce.string(), -}); - -export type NamespaceAuth = z.infer; - -export const DatabaseAuth = z.object({ - namespace: z.coerce.string(), - database: z.coerce.string(), - scope: z.never().optional(), - username: z.coerce.string(), - password: z.coerce.string(), -}); - -export type DatabaseAuth = z.infer; - -export const ScopeAuth = z.object({ - namespace: z.coerce.string().optional(), - database: z.coerce.string().optional(), - scope: z.coerce.string(), -}).catchall(z.unknown()); - -export type ScopeAuth = z.infer; - -export const AnyAuth = z.union([ - SuperUserAuth, - NamespaceAuth, - DatabaseAuth, - ScopeAuth, -]); -export type AnyAuth = z.infer; - -export const Token = z.string({ invalid_type_error: "Not a valid token" }); -export type Token = z.infer; - -export const TransformAuth = z.union([ - z.object({ - namespace: z.string().optional(), - database: z.string().optional(), - scope: z.never().optional(), - username: z.string(), - password: z.string(), - }).transform(({ - namespace, - database, - username, - password, - }) => { - const vars: Record = { - user: username, - pass: password, - }; - - if (namespace) { - vars.ns = namespace; - if (database) { - vars.db = database; - } +export function convertAuth(params: AnyAuth): Record { + const cloned: Record = { ...params }; + const convertString = (a: string, b: string, optional?: boolean) => { + if (a in params) { + cloned[b] = `${cloned[a]}`; + delete cloned[a]; + } else if (optional !== true) { + throw new Error(`Key ${a} is missing from the authentication parameters`); } + }; + + if ("access" in params) { + convertString("access", "ac"); + convertString("namespace", "ns"); + convertString("database", "db"); + } else if ("scope" in params) { + convertString("scope", "sc"); + convertString("namespace", "ns"); + convertString("database", "db"); + } else { + convertString("database", "db", !("namespace" in params)); + convertString("namespace", "ns", !("database" in params)); + convertString("username", "user"); + convertString("password", "pass"); + } + + return cloned; +} + +export type RootAuth = { + username: string; + password: string; +}; + +export type NamespaceAuth = { + namespace: string; + username: string; + password: string; +}; + +export type DatabaseAuth = { + namespace: string; + database: string; + username: string; + password: string; +}; + +export type ScopeAuth = { + namespace?: string; + database?: string; + scope: string; + [K: string]: unknown; +}; - return vars; - }), - z.object({ - namespace: z.string(), - database: z.string(), - scope: z.string(), - }).catchall(z.unknown()).transform(({ - namespace, - database, - scope, - ...rest - }) => ({ - ns: namespace, - db: database, - sc: scope, - ...rest, - })), -]); +export type AccessAuth = { + namespace?: string; + database?: string; + access: string; + [K: string]: unknown; +}; + +export type AnyAuth = + | RootAuth + | NamespaceAuth + | DatabaseAuth + | ScopeAuth + | AccessAuth; + +export type Token = string; ///////////////////////////////////// ////////// QUERY TYPES ////////// @@ -190,41 +160,6 @@ export type Patch = | MovePatch | TestPatch; -// Connection options - -export type ConnectionOptions = - & { - versionCheck?: boolean; - versionCheckTimeout?: number; - prepare?: (connection: Surreal) => unknown; - auth?: AnyAuth | Token; - } - & ( - | UseOptions - | { - namespace?: never; - database?: never; - } - ); - -export function processConnectionOptions({ - prepare, - auth, - namespace, - database, -}: ConnectionOptions) { - z.function().optional().parse(prepare); - z.union([Token, AnyAuth]).optional().parse(auth); - const useOpts = namespace || database - ? UseOptions.parse({ - namespace, - database, - }) - : { namespace: undefined, database: undefined }; - - return { prepare, auth, ...useOpts } satisfies ConnectionOptions; -} - // RPC export type RpcRequest< @@ -235,11 +170,11 @@ export type RpcRequest< params?: Params; }; -export type RpcResponse = +export type RpcResponse = | RpcResponseOk | RpcResponseErr; -export type RpcResponseOk = { +export type RpcResponseOk = { result: Result; error?: never; }; @@ -254,26 +189,33 @@ export type RpcResponseErr = { // Live -export const Action = z.union([ - z.literal("CREATE"), - z.literal("UPDATE"), - z.literal("DELETE"), -]); - -export type Action = z.infer; - -export const LiveResult = z.object({ - id: z.instanceof(UUID as never) as z.ZodType< - typeof UUID, - z.ZodTypeDef, - typeof UUID - >, - action: Action, - result: z.record(z.unknown()), -}); +export const liveActions = ["CREATE", "UPDATE", "DELETE"] as const; +export type LiveAction = (typeof liveActions)[number]; +export type LiveResult = { + id: Uuid; + action: LiveAction; + result: Record; +}; -export type LiveResult = z.infer; +export type LiveHandlerArguments< + Result extends Record | Patch = Record, +> = + | [action: LiveAction, result: Result] + | [action: "CLOSE", result: "killed" | "disconnected"]; export type LiveHandler< Result extends Record | Patch = Record, -> = (action: Action, result: Result) => unknown; +> = (...[action, result]: LiveHandlerArguments) => unknown; + +export function isLiveResult(v: unknown): v is LiveResult { + if (typeof v !== "object") return false; + if (v === null) return false; + if (!("id" in v && "action" in v && "result" in v)) return false; + + if (!(v.id instanceof Uuid)) return false; + if (!liveActions.includes(v.action as LiveAction)) return false; + if (typeof v.result !== "object") return false; + if (v.result === null) return false; + + return true; +} diff --git a/src/util/PreparedQuery.ts b/src/util/PreparedQuery.ts new file mode 100644 index 00000000..a388fca5 --- /dev/null +++ b/src/util/PreparedQuery.ts @@ -0,0 +1,24 @@ +import { + Encoded, + type Fill, + type PartiallyEncoded, + encode, + partiallyEncodeObject, +} from "../cbor"; + +export type ConvertMethod = (result: unknown[]) => T; +export class PreparedQuery< + C extends ConvertMethod | undefined = ConvertMethod, +> { + public readonly query: Encoded; + public readonly bindings: Record; + + constructor(query: string, bindings?: Record, convert?: C) { + this.query = new Encoded(encode(query)); + this.bindings = partiallyEncodeObject(bindings ?? {}); + } + + build(gaps?: Fill[]): ArrayBuffer { + return encode([this.query, this.bindings]); + } +} diff --git a/src/library/emitter.ts b/src/util/emitter.ts similarity index 69% rename from src/library/emitter.ts rename to src/util/emitter.ts index 1d033738..12b81468 100644 --- a/src/library/emitter.ts +++ b/src/util/emitter.ts @@ -4,32 +4,24 @@ export type Listener = ( export type UnknownEvents = Record; export class Emitter { - private collectable: Partial< - { - [K in keyof Events]: Events[K][]; - } - > = {}; + private collectable: Partial<{ + [K in keyof Events]: Events[K][]; + }> = {}; - private listeners: Partial< - { - [K in keyof Events]: Listener[]; - } - > = {}; + private listeners: Partial<{ + [K in keyof Events]: Listener[]; + }> = {}; - private readonly interceptors: Partial< - { - [K in keyof Events]: (...args: Events[K]) => Promise; - } - >; + private readonly interceptors: Partial<{ + [K in keyof Events]: (...args: Events[K]) => Promise; + }>; constructor({ interceptors, }: { - interceptors?: Partial< - { - [K in keyof Events]: (...args: Events[K]) => Promise; - } - >; + interceptors?: Partial<{ + [K in keyof Events]: (...args: Events[K]) => Promise; + }>; } = {}) { this.interceptors = interceptors ?? {}; } @@ -38,7 +30,7 @@ export class Emitter { event: Event, listener: Listener, historic = false, - ) { + ): void { if (!this.listeners[event]) { this.listeners[event] = []; } @@ -49,12 +41,17 @@ export class Emitter { if (historic && this.collectable[event]) { const buffer = this.collectable[event]; this.collectable[event] = []; - buffer?.forEach((args) => listener(...args)); + for (const args of buffer) { + listener(...args); + } } } } - subscribeOnce(event: Event, historic = false) { + subscribeOnce( + event: Event, + historic = false, + ): Promise { return new Promise((resolve) => { let resolved = false; const listener = (...args: Events[Event]) => { @@ -72,11 +69,9 @@ export class Emitter { unSubscribe( event: Event, listener: Listener, - ) { + ): void { if (this.listeners[event]) { - const index = this.listeners[event]?.findIndex((v) => - v == listener - ); + const index = this.listeners[event]?.findIndex((v) => v === listener); if (index) { this.listeners[event]?.splice(index, 1); } @@ -86,7 +81,7 @@ export class Emitter { isSubscribed( event: Event, listener: Listener, - ) { + ): boolean { return !!this.listeners[event]?.includes(listener); } @@ -94,11 +89,11 @@ export class Emitter { event: Event, args: Events[Event], collectable = false, - ) { + ): Promise { const interceptor = this.interceptors[event]; - args = interceptor ? await interceptor(...args) : args; + const computedArgs = interceptor ? await interceptor(...args) : args; - if (this.listeners[event]?.length == 0 && collectable) { + if (this.listeners[event]?.length === 0 && collectable) { if (!this.collectable[event]) { this.collectable[event] = []; } @@ -106,7 +101,9 @@ export class Emitter { this.collectable[event]?.push(args); } - this.listeners[event]?.forEach((listener) => listener(...args)); + for (const listener of this.listeners[event] ?? []) { + listener(...computedArgs); + } } reset({ @@ -115,9 +112,11 @@ export class Emitter { }: { collectable?: boolean | keyof Events | (keyof Events)[]; listeners?: boolean | keyof Events | (keyof Events)[]; - }) { + }): void { if (Array.isArray(collectable)) { - collectable.forEach((k) => delete this.collectable[k]); + for (const k of collectable) { + delete this.collectable[k]; + } } else if (typeof collectable === "string") { delete this.collectable[collectable]; } else if (collectable !== false) { @@ -125,7 +124,9 @@ export class Emitter { } if (Array.isArray(listeners)) { - listeners.forEach((k) => delete this.listeners[k]); + for (const k of listeners) { + delete this.listeners[k]; + } } else if (typeof listeners === "string") { delete this.listeners[listeners]; } else if (listeners !== false) { @@ -133,7 +134,7 @@ export class Emitter { } } - scanListeners(filter?: (k: keyof Events) => boolean) { + scanListeners(filter?: (k: keyof Events) => boolean): (keyof Events)[] { let listeners = Object.keys(this.listeners) as (keyof Events)[]; if (filter) listeners = listeners.filter(filter); return listeners; diff --git a/src/util/getIncrementalID.ts b/src/util/getIncrementalID.ts new file mode 100644 index 00000000..e70381b5 --- /dev/null +++ b/src/util/getIncrementalID.ts @@ -0,0 +1,5 @@ +let id = 0; +export function getIncrementalID(): string { + id = (id + 1) % Number.MAX_SAFE_INTEGER; + return id.toString(); +} diff --git a/src/util/jsonify.ts b/src/util/jsonify.ts new file mode 100644 index 00000000..0b46b324 --- /dev/null +++ b/src/util/jsonify.ts @@ -0,0 +1,76 @@ +import { + Decimal, + Duration, + Geometry, + RecordId, + StringRecordId, + Table, + Uuid, +} from "../data"; + +export type Jsonify = T extends + | Date + | Uuid + | Decimal + | Duration + | StringRecordId + ? string + : T extends undefined + ? never + : T extends Record | Array + ? { [K in keyof T]: Jsonify } + : T extends Map + ? Map> + : T extends Set + ? Set> + : T extends Geometry + ? ReturnType + : T extends RecordId + ? `${Tb}:${string}` + : T extends Table + ? `${Tb}` + : T; + +export function jsonify(input: T): Jsonify { + if (typeof input === "object") { + if (input === null) return null as Jsonify; + + // We only want to process "SurrealQL values" + if ( + input instanceof Date || + input instanceof Uuid || + input instanceof Decimal || + input instanceof Duration || + input instanceof StringRecordId || + input instanceof RecordId || + input instanceof Geometry || + input instanceof Table + ) { + return input.toJSON() as Jsonify; + } + + // We check by prototype, because we do not want to process derivatives of objects and arrays + switch (Object.getPrototypeOf(input)) { + case Object.prototype: { + const entries = Object.entries(input as object); + const mapped = entries + .map(([k, v]) => [k, jsonify(v)]) + .filter(([_, v]) => v !== undefined); + return Object.fromEntries(mapped) as Jsonify; + } + case Map.prototype: { + const entries = Array.from(input as [string, unknown][]); + const mapped = entries + .map(([k, v]) => [k, jsonify(v)]) + .filter(([_, v]) => v !== undefined); + return new Map(mapped as [string, unknown][]) as Jsonify; + } + case Array.prototype: + return (input as []).map(jsonify) as Jsonify; + case Set.prototype: + return new Set([...(input as [])].map(jsonify)) as Jsonify; + } + } + + return input as Jsonify; +} diff --git a/src/util/processAuthVars.ts b/src/util/processAuthVars.ts new file mode 100644 index 00000000..42d65639 --- /dev/null +++ b/src/util/processAuthVars.ts @@ -0,0 +1,24 @@ +import { NoDatabaseSpecified, NoNamespaceSpecified } from "../errors.ts"; +import type { AnyAuth } from "../types.ts"; + +export function processAuthVars( + vars: T, + fallback?: { + namespace?: string; + database?: string; + }, +): AnyAuth { + if ("scope" in vars || "access" in vars) { + if (!vars.namespace) { + if (!fallback?.namespace) throw new NoNamespaceSpecified(); + vars.namespace = fallback.namespace; + } + + if (!vars.database) { + if (!fallback?.database) throw new NoDatabaseSpecified(); + vars.database = fallback.database; + } + } + + return vars; +} diff --git a/src/library/tagged-template.ts b/src/util/tagged-template.ts similarity index 53% rename from src/library/tagged-template.ts rename to src/util/tagged-template.ts index 236c344e..97f90155 100644 --- a/src/library/tagged-template.ts +++ b/src/util/tagged-template.ts @@ -3,22 +3,20 @@ import { PreparedQuery } from "./PreparedQuery.ts"; export function surrealql( query_raw: string[] | TemplateStringsArray, ...values: unknown[] -) { - const mapped_bindings = values.map((v, i) => - [`__tagged_template_literal_binding__${i}`, v] as const +): PreparedQuery { + const mapped_bindings = values.map((v, i) => [`bind___${i}`, v] as const); + const bindings = mapped_bindings.reduce>( + (prev, [k, v]) => { + prev[k] = v; + return prev; + }, + {}, ); - const bindings = mapped_bindings.reduce((prev, [k, v]) => ({ - ...prev, - [k]: v, - }), {}); const query = query_raw .flatMap((segment, i) => { const variable = mapped_bindings[i]?.[0]; - return [ - segment, - ...(variable ? [`$${variable}`] : []), - ]; + return [segment, ...(variable ? [`$${variable}`] : [])]; }) .join(""); diff --git a/src/util/versionCheck.ts b/src/util/versionCheck.ts new file mode 100644 index 00000000..f0837871 --- /dev/null +++ b/src/util/versionCheck.ts @@ -0,0 +1,77 @@ +import { UnsupportedVersion, VersionRetrievalFailure } from "../errors.ts"; + +type Version = `${number}.${number}.${number}`; +export const defaultVersionCheckTimeout = 5000; +export const supportedSurrealDbVersionMin: Version = "1.4.2"; +export const supportedSurrealDbVersionUntil: Version = "3.0.0"; +export const supportedSurrealDbVersionRange: string = `>= ${supportedSurrealDbVersionMin} < ${supportedSurrealDbVersionUntil}`; + +export function versionCheck( + version: string, + min: Version = supportedSurrealDbVersionMin, + until: Version = supportedSurrealDbVersionUntil, +): true { + if (!isVersionSupported(version, min, until)) { + throw new UnsupportedVersion(version, `>= ${min} < ${until}`); + } + + return true; +} + +export function isVersionSupported( + version: string, + min: Version = supportedSurrealDbVersionMin, + until: Version = supportedSurrealDbVersionUntil, +): boolean { + return ( + min.localeCompare(version, undefined, { + numeric: true, + }) <= 0 && + until.localeCompare(version, undefined, { + numeric: true, + }) === 1 + ); +} + +export async function retrieveRemoteVersion( + url: URL, + timeout?: number, +): Promise { + const mappedProtocols = { + "ws:": "http:", + "wss:": "https:", + "http:": "http:", + "https:": "https:", + } as Record; + + const protocol = mappedProtocols[url.protocol]; + if (protocol) { + const basepath = url.pathname.slice(0, -4); + // biome-ignore lint/style/noParameterAssign: need to clone URL instance to prevent altering the original + url = new URL(url); + url.pathname = `${basepath}/version`; + url.protocol = protocol; + + const controller = new AbortController(); + const id = setTimeout( + () => controller.abort(), + timeout ?? defaultVersionCheckTimeout, + ); + const versionPrefix = "surrealdb-"; + const version = await fetch(url, { + signal: controller.signal, + }) + .then((res) => res.text()) + .then((version) => version.slice(versionPrefix.length)) + .catch((e) => { + throw new VersionRetrievalFailure(e); + }) + .finally(() => { + clearTimeout(id); + }); + + return version as Version; + } + + throw new VersionRetrievalFailure(); +} diff --git a/tests/integration/env.ts b/tests/integration/env.ts index a0758206..2679372d 100644 --- a/tests/integration/env.ts +++ b/tests/integration/env.ts @@ -1,17 +1,17 @@ -import { getAvailablePortSync } from "https://deno.land/x/port@1.0.0/mod.ts"; +import getPort from "get-port"; -const port = getAvailablePortSync(); -if (typeof port != "number") throw new Error("Could not claim port"); +const port = await getPort(); +if (typeof port !== "number") throw new Error("Could not claim port"); -const port_unreachable = getAvailablePortSync(); -if (typeof port_unreachable != "number") { +const port_unreachable = await getPort(); +if (typeof port_unreachable !== "number") { throw new Error("Could not claim port"); } -export const SURREAL_PORT = port.toString(); -export const SURREAL_BIND = `0.0.0.0:${SURREAL_PORT}`; -export const SURREAL_PORT_UNREACHABLE = port_unreachable.toString(); -export const SURREAL_BIND_UNREACHABLE = `0.0.0.0:${SURREAL_PORT}`; +export const SURREAL_PORT: string = port.toString(); +export const SURREAL_BIND: string = `0.0.0.0:${SURREAL_PORT}`; +export const SURREAL_PORT_UNREACHABLE: string = port_unreachable.toString(); +export const SURREAL_BIND_UNREACHABLE: string = `0.0.0.0:${SURREAL_PORT}`; export const SURREAL_USER = "root"; export const SURREAL_PASS = "root"; export const SURREAL_NS = "test"; diff --git a/tests/integration/http.ts b/tests/integration/http.ts deleted file mode 100644 index fc8e725d..00000000 --- a/tests/integration/http.ts +++ /dev/null @@ -1,2 +0,0 @@ -globalThis.protocol = "http"; -import "./mod.ts"; diff --git a/tests/integration/mod.ts b/tests/integration/mod.ts deleted file mode 100644 index 3076daeb..00000000 --- a/tests/integration/mod.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { SURREAL_BIND, SURREAL_PASS, SURREAL_USER } from "./env.ts"; - -new Deno.Command("surreal", { - args: ["start"], - env: { - SURREAL_BIND, - SURREAL_USER, - SURREAL_PASS, - }, -}).spawn(); - -await new Promise((r) => setTimeout(r, 1000)); - -import "./tests/auth.ts"; -import "./tests/querying.ts"; -import "./tests/live.ts"; -import "./tests/connection.ts"; diff --git a/tests/integration/surreal.ts b/tests/integration/surreal.ts index a59617d6..c6bee0d7 100644 --- a/tests/integration/surreal.ts +++ b/tests/integration/surreal.ts @@ -1,43 +1,21 @@ -import Surreal from "../../mod.ts"; -import { SURREAL_PORT_UNREACHABLE, SURREAL_USER } from "./env.ts"; +import { afterAll } from "bun:test"; +import Surreal, { type AnyAuth } from "../../src"; +import { SURREAL_BIND, SURREAL_PORT_UNREACHABLE, SURREAL_USER } from "./env.ts"; import { SURREAL_PASS } from "./env.ts"; import { SURREAL_DB } from "./env.ts"; import { SURREAL_NS } from "./env.ts"; import { SURREAL_PORT } from "./env.ts"; -type Protocol = "ws" | "http"; -declare global { - // deno-lint-ignore no-var - var protocol: Protocol | undefined; -} +export type Protocol = "http" | "ws"; +export const PROTOCOL: Protocol = + process.env.SURREAL_PROTOCOL === "http" ? "http" : "ws"; -export async function createSurreal({ - protocol, - auth, - reachable, -}: { - protocol?: Protocol; - auth?: PremadeAuth; - reachable?: boolean; -} = {}) { - protocol = protocol - ? protocol - : "protocol" in globalThis - ? globalThis.protocol as Protocol - : "ws"; - const surreal = new Surreal(); - const port = reachable == false ? SURREAL_PORT_UNREACHABLE : SURREAL_PORT; - await surreal.connect(`${protocol}://127.0.0.1:${port}/rpc`, { - namespace: SURREAL_NS, - database: SURREAL_DB, - auth: auth ? createAuth(auth) : auth, - }); - - return surreal; +declare global { + var surrealProc: number; } type PremadeAuth = "root" | "invalid"; -export function createAuth(auth: PremadeAuth) { +export function createAuth(auth: PremadeAuth): AnyAuth { switch (auth) { case "root": { return { @@ -55,3 +33,47 @@ export function createAuth(auth: PremadeAuth) { throw new Error("Invalid auth option"); } } + +type CreateSurrealOptions = { + protocol?: Protocol; + auth?: PremadeAuth; + reachable?: boolean; +}; + +export async function setupServer(): Promise<{ + createSurreal: (options?: CreateSurrealOptions) => Promise; +}> { + const proc = Bun.spawn(["surreal", "start"], { + env: { + SURREAL_BIND, + SURREAL_USER, + SURREAL_PASS, + }, + }); + + await Bun.sleep(1000); + + afterAll(async () => { + proc.kill(); + }); + + async function createSurreal({ + protocol, + auth, + reachable, + }: CreateSurrealOptions = {}) { + protocol = protocol ? protocol : PROTOCOL; + const surreal = new Surreal(); + const port = reachable === false ? SURREAL_PORT_UNREACHABLE : SURREAL_PORT; + await surreal.connect(`${protocol}://127.0.0.1:${port}/rpc`, { + namespace: SURREAL_NS, + database: SURREAL_DB, + auth: createAuth(auth ?? "root"), + }); + + afterAll(async () => await surreal.close()); + return surreal; + } + + return { createSurreal }; +} diff --git a/tests/integration/tests/auth.test.ts b/tests/integration/tests/auth.test.ts new file mode 100644 index 00000000..983fb67f --- /dev/null +++ b/tests/integration/tests/auth.test.ts @@ -0,0 +1,95 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { RecordId, ResponseError } from "../../../src"; +import { createAuth, setupServer } from "../surreal.ts"; + +const { createSurreal } = await setupServer(); + +describe("basic auth", async () => { + const surreal = await createSurreal(); + + test("root signin", async () => { + const res = await surreal.signin(createAuth("root")); + expect(typeof res).toBe("string"); + }); + + test("invalid credentials", async () => { + const req = surreal.signin(createAuth("invalid")); + expect(req).rejects.toBeInstanceOf(ResponseError); + }); +}); + +describe("scope auth", async () => { + const surreal = await createSurreal(); + const version = await surreal.version(); + if (!version.startsWith("surrealdb-1")) return; + + beforeAll(async () => { + await surreal.query(/* surql */ ` + DEFINE TABLE user PERMISSIONS FOR select WHERE id = $auth; + DEFINE SCOPE user + SIGNUP ( CREATE type::thing('user', $id) ) + SIGNIN ( SELECT * FROM type::thing('user', $id) ); + `); + }); + + test("scope signup", async () => { + const signup = await surreal.signup({ + scope: "user", + id: 123, + }); + + expect(typeof signup).toBe("string"); + }); + + test("scope signin", async () => { + const signin = await surreal.signin({ + scope: "user", + id: 123, + }); + + expect(typeof signin).toBe("string"); + }); + + test("info", async () => { + const info = await surreal.info<{ id: RecordId<"user"> }>(); + expect(info).toMatchObject({ id: new RecordId("user", 123) }); + }); +}); + +describe("record auth", async () => { + const surreal = await createSurreal(); + const version = await surreal.version(); + if (version.startsWith("surrealdb-1")) return; + + beforeAll(async () => { + await surreal.query(/* surql */ ` + DEFINE TABLE user PERMISSIONS FOR select WHERE id = $auth; + DEFINE ACCESS user ON DATABASE TYPE RECORD + SIGNUP ( CREATE type::thing('user', $id) ) + SIGNIN ( SELECT * FROM type::thing('user', $id) ); + `); + }); + + test("record signup", async () => { + const signup = await surreal.signup({ + access: "user", + id: 123, + }); + + expect(typeof signup).toBe("string"); + }); + + test("record signin", async () => { + const signin = await surreal.signin({ + access: "user", + id: 123, + }); + + expect(typeof signin).toBe("string"); + }); + + test("info", async () => { + const info = await surreal.info<{ id: RecordId<"user"> }>(); + expect(info).toMatchObject({ id: new RecordId("user", 123) }); + }); +}); diff --git a/tests/integration/tests/auth.ts b/tests/integration/tests/auth.ts deleted file mode 100644 index 27711eaa..00000000 --- a/tests/integration/tests/auth.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { assertRejects } from "https://deno.land/std@0.223.0/assert/assert_rejects.ts"; -import { createAuth } from "../surreal.ts"; -import { createSurreal } from "../surreal.ts"; - -import { assertEquals } from "https://deno.land/std@0.223.0/assert/mod.ts"; -import { RecordId, ResponseError } from "../../../mod.ts"; - -Deno.test("root signin", async () => { - const surreal = await createSurreal(); - - const res = await surreal.signin(createAuth("root")).catch(() => false); - assertEquals(typeof res, "string", "Returned token to be string"); - - await surreal.close(); -}); - -Deno.test("invalid credentials", async () => { - const surreal = await createSurreal(); - - const req = () => surreal.signin(createAuth("invalid")); - await assertRejects(req, ResponseError); - - await surreal.close(); -}); - -Deno.test("scope signup/signin/info", async () => { - const surreal = await createSurreal(); - - await surreal.query( - /* surql */ ` - DEFINE TABLE user PERMISSIONS FOR select WHERE id = $auth; - DEFINE SCOPE user - SIGNUP ( CREATE type::thing('user', $id) ) - SIGNIN ( SELECT * FROM type::thing('user', $id) ); - `, - ); - - { - const signup = await surreal.signup({ - scope: "user", - id: 123, - }); - - assertEquals(typeof signup, "string", "scope signin"); - } - - { - const signin = await surreal.signin({ - scope: "user", - id: 123, - }); - - assertEquals(typeof signin, "string", "scope signin"); - } - - { - const info = await surreal.info<{ id: RecordId<"user"> }>(); - assertEquals(info, { id: new RecordId("user", 123) }, "scope info"); - } - - await surreal.close(); -}); diff --git a/tests/integration/tests/connection.test.ts b/tests/integration/tests/connection.test.ts new file mode 100644 index 00000000..b3d38bd0 --- /dev/null +++ b/tests/integration/tests/connection.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { + VersionRetrievalFailure, + defaultVersionCheckTimeout, +} from "../../../src"; +import { setupServer } from "../surreal.ts"; + +const { createSurreal } = await setupServer(); + +describe("version check", async () => { + test("check version", async () => { + const surreal = await createSurreal(); + + const res = await surreal.version(); + expect(res.startsWith("surrealdb-")).toBe(true); + }); + + test("version check timeout", async () => { + const start = new Date(); + const res = createSurreal({ reachable: false }); + const end = new Date(); + const diff = end.getTime() - start.getTime(); + + expect(res).rejects.toBeInstanceOf(VersionRetrievalFailure); + expect(diff).toBeLessThanOrEqual(defaultVersionCheckTimeout + 100); // 100ms margin + }); +}); diff --git a/tests/integration/tests/connection.ts b/tests/integration/tests/connection.ts deleted file mode 100644 index 3f225639..00000000 --- a/tests/integration/tests/connection.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { assertInstanceOf } from "https://deno.land/std@0.223.0/assert/assert_instance_of.ts"; -import { createSurreal } from "../surreal.ts"; -import { assertEquals } from "https://deno.land/std@0.223.0/assert/mod.ts"; -import { VersionRetrievalFailure } from "../../../mod.ts"; -import { assertLess } from "https://deno.land/std@0.223.0/assert/assert_less.ts"; - -Deno.test("check version", async () => { - const surreal = await createSurreal(); - const res = await surreal.version(); - - assertEquals(res.startsWith("surrealdb-"), true, "Version to be returned"); - - await surreal.close(); -}); - -Deno.test("version check timeout", async () => { - const start = new Date(); - const res = await createSurreal({ reachable: false }).catch((err) => err); - const end = new Date(); - const diff = end.getTime() - start.getTime(); - - assertInstanceOf(res, VersionRetrievalFailure); - assertLess(diff, 5100); // 100ms margin -}); diff --git a/tests/integration/tests/live.test.ts b/tests/integration/tests/live.test.ts new file mode 100644 index 00000000..042096b1 --- /dev/null +++ b/tests/integration/tests/live.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import { + type LiveAction, + type LiveHandlerArguments, + RecordId, + ResponseError, + type Surreal, + Uuid, +} from "../../../src"; +import { setupServer } from "../surreal.ts"; + +const { createSurreal } = await setupServer(); + +const isHttp = (surreal: Surreal) => + surreal.connection?.connection.url?.protocol.startsWith("http"); + +describe("Live Queries HTTP", async () => { + const surreal = await createSurreal(); + if (!isHttp(surreal)) return; + + test("not supported", () => { + expect(surreal.live("person")).rejects.toBeInstanceOf(ResponseError); + }); +}); + +describe("Live Queries WS", async () => { + const surreal = await createSurreal(); + if (isHttp(surreal)) return; + + test("live", async () => { + const events = new CollectablePromise<{ + action: LiveHandlerArguments[0]; + result: LiveHandlerArguments[1]; + }>(3); + + const queryUuid = await surreal.live("person", (action, result) => { + if (action === "CLOSE") return; + events.push({ action, result }); + }); + + expect(queryUuid).toBeInstanceOf(Uuid); + + // Create some live notifications + await surreal.create(new RecordId("person", 1), { + firstname: "John", + lastname: "Doe", + }); + await surreal.update(new RecordId("person", 1), { + firstname: "Jane", + lastname: "Doe", + }); + await surreal.delete(new RecordId("person", 1)); + + expect(events.then((a) => a)).resolves.toMatchObject([ + { + action: "CREATE", + result: { + id: new RecordId("person", 1), + firstname: "John", + lastname: "Doe", + }, + }, + { + action: "UPDATE", + result: { + id: new RecordId("person", 1), + firstname: "Jane", + lastname: "Doe", + }, + }, + { + action: "DELETE", + result: { + id: new RecordId("person", 1), + firstname: "Jane", + lastname: "Doe", + }, + }, + ]); + }); + + test("unsubscribe", async () => { + // Prepare + let primaryCount = 0; + let secondaryCount = 0; + function secondaryHandler(...[action]: LiveHandlerArguments) { + if (action === "CLOSE") return; + secondaryCount += 1; + // Unsubscribe secondary listener + surreal.unSubscribeLive(primaryUuid, secondaryHandler); + } + + const events = new CollectablePromise<{ + action: LiveHandlerArguments[0]; + result: LiveHandlerArguments[1]; + }>(3); + + // Start live query and register secondary handler + const primaryUuid = await surreal.live("person", (action, result) => { + if (action === "CLOSE") return; + events.push({ action, result }); + primaryCount += 1; + }); + + await surreal.subscribeLive(primaryUuid, secondaryHandler); + + // Create events + await surreal.create(new RecordId("person", 1), { + firstname: "John", + }); + + await surreal.update(new RecordId("person", 1), { + firstname: "Jane", + }); + await surreal.delete(new RecordId("person", 1)); + + // Wait for all events to be collected + await events; + + await surreal.kill(primaryUuid); + + // Check counts + expect(primaryCount).toBeGreaterThan(secondaryCount); + }); + + test("kill", async () => { + // Prepare + let primaryCount = 0; + let secondaryCount = 0; + + const events = new CollectablePromise<{ + action: LiveHandlerArguments[0]; + result: LiveHandlerArguments[1]; + }>(3); + + // Start live query and register secondary handler + const primaryUuid = await surreal.live("person", (action, result) => { + if (action === "CLOSE") return; + events.push({ action, result }); + primaryCount += 1; + }); + + const secondaryUuid = await surreal.live("person", (action, _) => { + if (action === "CLOSE") return; + secondaryCount += 1; + // Kill secondary live query + surreal.kill(secondaryUuid); + }); + + // Create events + await surreal.create(new RecordId("person", 1), { + firstname: "John", + }); + + await surreal.update(new RecordId("person", 1), { + firstname: "Jane", + }); + await surreal.delete(new RecordId("person", 1)); + + // Wait for all events to be collected + await events; + + await surreal.kill(primaryUuid); + + // Check counts + expect(primaryCount).toBeGreaterThan(secondaryCount); + }); +}); + +class CollectablePromise { + [Symbol.toStringTag] = "CollectablePromise"; + private collection: Result = [] as unknown as Result; + private promise: Promise; + private resolve: (value: Result) => void; + private amount: number; + + constructor(amount: number) { + const { promise, resolve } = Promise.withResolvers(); + this.amount = amount; + this.promise = promise; + this.resolve = resolve; + } + + push(value: T): void { + this.collection.push(value); + if (this.collection.length >= this.amount) this.resolve(this.collection); + } + + // biome-ignore lint/suspicious/noThenProperty: We are intentionally replicating a promise here + then( + onfulfilled?: + | ((value: Result) => Result | PromiseLike) + | undefined + | null, + onrejected?: + | ((reason: unknown) => Err | PromiseLike) + | undefined + | null, + ): Promise { + return this.promise.then(onfulfilled, onrejected); + } + + /** + * Attaches a callback for only the rejection of the Promise. + * @param onrejected The callback to execute when the Promise is rejected. + * @returns A Promise for the completion of the callback. + */ + catch( + onrejected?: ((reason: Err) => Err | PromiseLike) | undefined | null, + ): Promise { + return this.promise.catch(onrejected); + } +} diff --git a/tests/integration/tests/live.ts b/tests/integration/tests/live.ts deleted file mode 100644 index 6d419843..00000000 --- a/tests/integration/tests/live.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { createSurreal } from "../surreal.ts"; - -import { - assert, - assertEquals, - assertGreater, - assertNotEquals, - assertRejects, -} from "https://deno.land/std@0.223.0/assert/mod.ts"; -import { type Action, RecordId, UUID } from "../../../mod.ts"; - -async function withTimeout(p: Promise, ms: number): Promise { - const { promise, resolve, reject } = Promise.withResolvers(); - const timeout = setTimeout(() => reject(new Error("Timeout")), ms); - - await Promise.race([ - promise, - p.then(resolve).finally(() => clearTimeout(timeout)), - ]); -} - -Deno.test("live", async () => { - const surreal = await createSurreal(); - - if (surreal.connection?.connection.url?.protocol !== "ws:") { - await assertRejects(async () => { - await surreal.live("person"); - }); - } else { - const events: { action: Action; result: Record }[] = - []; - const { promise, resolve } = Promise.withResolvers(); - - const queryUuid = await surreal.live("person", (action, result) => { - events.push({ action, result }); - if (action === "DELETE") resolve(); - }); - - assert(queryUuid instanceof UUID); - - await surreal.create(new RecordId("person", 1), { - firstname: "John", - lastname: "Doe", - }); - await surreal.update(new RecordId("person", 1), { - firstname: "Jane", - lastname: "Doe", - }); - await surreal.delete(new RecordId("person", 1)); - - await withTimeout(promise, 5e3); // Wait for the DELETE event - - assertEquals(events, [ - { - action: "CREATE", - result: { - id: new RecordId("person", 1), - firstname: "John", - lastname: "Doe", - }, - }, - { - action: "UPDATE", - result: { - id: new RecordId("person", 1), - firstname: "Jane", - lastname: "Doe", - }, - }, - { - action: "DELETE", - result: { - id: new RecordId("person", 1), - firstname: "Jane", - lastname: "Doe", - }, - }, - ]); - } - - await surreal.close(); -}); - -Deno.test("unsubscribe live", async () => { - const surreal = await createSurreal(); - - if (surreal.connection?.connection.url?.protocol !== "ws:") { - // Not supported - } else { - const { promise, resolve } = Promise.withResolvers(); - - let primaryLiveHandlerCallCount = 0; - let secondaryLiveHandlerCallCount = 0; - - const primaryLiveHandler = () => { - primaryLiveHandlerCallCount += 1; - }; - const secondaryLiveHandler = () => { - secondaryLiveHandlerCallCount += 1; - }; - - const queryUuid = await surreal.live("person", (action: Action) => { - if (action === "DELETE") resolve(); - }); - await surreal.subscribeLive(queryUuid, primaryLiveHandler); - await surreal.subscribeLive(queryUuid, secondaryLiveHandler); - - await surreal.create(new RecordId("person", 1), { firstname: "John" }); - - await surreal.unSubscribeLive(queryUuid, secondaryLiveHandler); - - await surreal.update(new RecordId("person", 1), { firstname: "Jane" }); - await surreal.delete(new RecordId("person", 1)); - - await withTimeout(promise, 5e3); // Wait for the DELETE event - - assertGreater( - primaryLiveHandlerCallCount, - secondaryLiveHandlerCallCount, - ); - } - - await surreal.close(); -}); - -Deno.test("kill", async () => { - const surreal = await createSurreal(); - - if (surreal.connection?.connection.url?.protocol !== "ws:") { - // Not supported - } else { - const { promise, resolve } = Promise.withResolvers(); - - let primaryLiveHandlerCallCount = 0; - let secondaryLiveHandlerCallCount = 0; - - const primaryLiveHandler = (action: Action) => { - primaryLiveHandlerCallCount += 1; - if (action === "DELETE") resolve(); - }; - const secondaryLiveHandler = () => { - secondaryLiveHandlerCallCount += 1; - }; - - const primaryQueryUuid = await surreal.live( - "person", - primaryLiveHandler, - ); - const secondaryQueryUuid = await surreal.live( - "person", - secondaryLiveHandler, - ); - - assertNotEquals( - primaryQueryUuid.toString(), - secondaryQueryUuid.toString(), - ); - - await surreal.create(new RecordId("person", 1), { firstname: "John" }); - - await surreal.kill(secondaryQueryUuid); - - await surreal.update(new RecordId("person", 1), { firstname: "Jane" }); - await surreal.delete(new RecordId("person", 1)); - - await withTimeout(promise, 5e3); // Wait for the DELETE event - - assertGreater( - primaryLiveHandlerCallCount, - secondaryLiveHandlerCallCount, - ); - } - - await surreal.close(); -}); diff --git a/tests/integration/tests/querying.test.ts b/tests/integration/tests/querying.test.ts new file mode 100644 index 00000000..66f46dde --- /dev/null +++ b/tests/integration/tests/querying.test.ts @@ -0,0 +1,429 @@ +import { describe, expect, test } from "bun:test"; +import { + Duration, + Gap, + GeometryCollection, + GeometryLine, + GeometryMultiPolygon, + GeometryPoint, + GeometryPolygon, + RecordId, + StringRecordId, + Table, + Uuid, + surql, +} from "../../../src"; +import { setupServer } from "../surreal.ts"; + +const { createSurreal } = await setupServer(); + +type Person = { + id: RecordId<"person">; + firstname: string; + lastname: string; + age?: number; +}; + +describe("create", async () => { + const surreal = await createSurreal(); + + test("single", async () => { + const single = await surreal.create>( + new RecordId("person", 1), + { + firstname: "John", + lastname: "Doe", + }, + ); + + expect(single).toStrictEqual({ + id: new RecordId("person", 1), + firstname: "John", + lastname: "Doe", + }); + }); + + test("multiple", async () => { + const multiple = await surreal.create("person", { + id: new RecordId("person", 2), + firstname: "Mary", + lastname: "Doe", + }); + + expect(multiple).toStrictEqual([ + { + id: new RecordId("person", 2), + firstname: "Mary", + lastname: "Doe", + }, + ]); + }); +}); + +describe("select", async () => { + const surreal = await createSurreal(); + + test("single", async () => { + const single = await surreal.select(new RecordId("person", 1)); + + expect(single).toStrictEqual({ + id: new RecordId("person", 1), + firstname: "John", + lastname: "Doe", + }); + }); + + test("multiple", async () => { + const multiple = await surreal.select("person"); + + expect(multiple).toStrictEqual([ + { + id: new RecordId("person", 1), + firstname: "John", + lastname: "Doe", + }, + { + id: new RecordId("person", 2), + firstname: "Mary", + lastname: "Doe", + }, + ]); + }); +}); + +describe("merge", async () => { + const surreal = await createSurreal(); + + test("single", async () => { + const single = await surreal.merge(new RecordId("person", 1), { + age: 20, + }); + + expect(single).toStrictEqual({ + id: new RecordId("person", 1), + firstname: "John", + lastname: "Doe", + age: 20, + }); + }); + + test("multiple", async () => { + const multiple = await surreal.merge("person", { age: 25 }); + + expect(multiple).toStrictEqual([ + { + id: new RecordId("person", 1), + firstname: "John", + lastname: "Doe", + age: 25, + }, + { + id: new RecordId("person", 2), + firstname: "Mary", + lastname: "Doe", + age: 25, + }, + ]); + }); +}); + +describe("update", async () => { + const surreal = await createSurreal(); + + test("single", async () => { + const single = await surreal.update>( + new RecordId("person", 1), + { + firstname: "John", + lastname: "Doe", + }, + ); + + expect(single).toStrictEqual({ + id: new RecordId("person", 1), + firstname: "John", + lastname: "Doe", + }); + }); + + test("multiple", async () => { + const multiple = await surreal.update>( + "person", + { + firstname: "Mary", + lastname: "Doe", + }, + ); + + expect(multiple).toStrictEqual([ + { + id: new RecordId("person", 1), + firstname: "Mary", + lastname: "Doe", + }, + { + id: new RecordId("person", 2), + firstname: "Mary", + lastname: "Doe", + }, + ]); + }); +}); + +describe("patch", async () => { + const surreal = await createSurreal(); + + test("single", async () => { + const single = await surreal.patch(new RecordId("person", 1), [ + { op: "replace", path: "/firstname", value: "John" }, + ]); + + expect(single).toStrictEqual({ + id: new RecordId("person", 1), + firstname: "John", + lastname: "Doe", + }); + }); + + test("multiple", async () => { + const multiple = await surreal.patch("person", [ + { op: "replace", path: "/age", value: 30 }, + ]); + + expect(multiple).toStrictEqual([ + { + id: new RecordId("person", 1), + firstname: "John", + lastname: "Doe", + age: 30, + }, + { + id: new RecordId("person", 2), + firstname: "Mary", + lastname: "Doe", + age: 30, + }, + ]); + }); + + test("single diff", async () => { + const singleDiff = await surreal.patch( + new RecordId("person", 1), + [{ op: "replace", path: "/age", value: 25 }], + true, + ); + + expect(singleDiff).toStrictEqual([ + { op: "replace", path: "/age", value: 25 }, + ]); + }); + + test("multiple diff", async () => { + const multipleDiff = await surreal.patch( + "person", + [{ op: "replace", path: "/age", value: 20 }], + true, + ); + + expect(multipleDiff).toStrictEqual([ + [{ op: "replace", path: "/age", value: 20 }], + [{ op: "replace", path: "/age", value: 20 }], + ]); + }); +}); + +describe("delete", async () => { + const surreal = await createSurreal(); + + test("single", async () => { + const single = await surreal.delete(new RecordId("person", 1)); + + expect(single).toStrictEqual({ + id: new RecordId("person", 1), + firstname: "John", + lastname: "Doe", + age: 20, + }); + }); + + test("multiple", async () => { + const multiple = await surreal.delete("person"); + + expect(multiple).toStrictEqual([ + { + id: new RecordId("person", 2), + firstname: "Mary", + lastname: "Doe", + age: 20, + }, + ]); + }); +}); + +describe("relate", async () => { + const surreal = await createSurreal(); + const version = await surreal.version(); + if (version === "surrealdb-1.4.2") return; + + test("single", async () => { + const single = await surreal.relate( + new RecordId("edge", "in"), + new RecordId("graph", 1), + new RecordId("edge", "out"), + { + num: 123, + }, + ); + + expect(single).toStrictEqual({ + id: new RecordId("graph", 1), + in: new RecordId("edge", "in"), + out: new RecordId("edge", "out"), + num: 123, + }); + }); + + test("multiple", async () => { + const multiple = await surreal.relate( + new RecordId("edge", "in"), + "graph", + new RecordId("edge", "out"), + { + id: new RecordId("graph", 2), + num: 456, + }, + ); + + expect(multiple).toStrictEqual([ + { + id: new RecordId("graph", 2), + in: new RecordId("edge", "in"), + out: new RecordId("edge", "out"), + num: 456, + }, + ]); + }); +}); + +test("run", async () => { + const surreal = await createSurreal(); + const version = await surreal.version(); + if (version === "surrealdb-1.4.2") return; + + const res = await surreal.run("array::add", [[1, 2], 3]); + expect(res).toMatchObject([1, 2, 3]); +}); + +describe("template literal", async () => { + const surreal = await createSurreal(); + + test("with gap", async () => { + const name = new Gap(); + const query = surql`CREATE ONLY person:test SET name = ${name}`; + const res = await surreal.query(query, [name.fill("test")]); + expect(res).toStrictEqual([ + { + id: new RecordId("person", "test"), + name: "test", + }, + ]); + }); + + test("with defined connection variables", async () => { + await surreal.let("test1", 123); + const gap = new Gap(); + const query = surql`RETURN [$test1, ${456}, ${gap}]`; + const res = await surreal.query(query, [gap.fill(789)]); + expect(res).toStrictEqual([[123, 456, 789]]); + }); +}); + +test("query", async () => { + const surreal = await createSurreal(); + + const input = { + // Native + string: "Hello World!", + number: 123, + float: 123.456, + true: true, + false: false, + null: null, + undefined: undefined, + array: [123], + object: { num: 456 }, + date: new Date(), + + // Custom + // Decimals are currently bugged on SurrealDB side of decoding + // decimal: new Decimal("123.456"), + rid: new RecordId("some-custom", [ + "recordid", + { with_an: "object" }, + undefined, + ]), + uuidv4: Uuid.v4(), + uuidv7: Uuid.v7(), + duration: new Duration("1w1d1h1s1ms"), + geometries: new GeometryCollection([ + new GeometryPoint([1, 2]), + new GeometryMultiPolygon([ + new GeometryPolygon([ + new GeometryLine([ + new GeometryPoint([1, 2]), + new GeometryPoint([3, 4]), + ]), + new GeometryLine([ + new GeometryPoint([5, 6]), + new GeometryPoint([7, 8]), + ]), + ]), + ]), + ]), + }; + + const [output] = await surreal.query<[typeof input]>(/* surql */ "$input", { + input, + }); + + expect(output).toStrictEqual(input); +}); + +test("record id bigint", async () => { + const surreal = await createSurreal(); + + const [output] = await surreal.query<[{ id: RecordId }]>( + /* surql */ "CREATE ONLY $id", + { + id: new RecordId("person", 90071992547409915n), + }, + ); + + expect(output.id).toStrictEqual(new RecordId("person", 90071992547409915n)); +}); + +test("string record id", async () => { + const surreal = await createSurreal(); + + const [output] = await surreal.query<[{ id: RecordId }]>( + /* surql */ "CREATE ONLY $id", + { + id: new StringRecordId("person:123"), + }, + ); + + expect(output.id).toStrictEqual(new RecordId("person", 123)); +}); + +test("table", async () => { + const surreal = await createSurreal(); + + const [output] = await surreal.query<[Table]>( + /* surql */ "RETURN type::table($table)", + { + table: "person", + }, + ); + + expect(output).toStrictEqual(new Table("person")); +}); diff --git a/tests/integration/tests/querying.ts b/tests/integration/tests/querying.ts deleted file mode 100644 index 13eb9179..00000000 --- a/tests/integration/tests/querying.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { createSurreal } from "../surreal.ts"; - -import { assertEquals } from "https://deno.land/std@0.223.0/assert/mod.ts"; -import { - Duration, - GeometryCollection, - GeometryLine, - GeometryMultiPolygon, - GeometryPoint, - GeometryPolygon, - RecordId, - StringRecordId, - Table, - UUID, - uuidv4, - uuidv7, -} from "../../../mod.ts"; - -type Person = { - id: RecordId<"person">; - firstname: string; - lastname: string; - age?: number; -}; - -Deno.test("create", async () => { - const surreal = await createSurreal(); - - const single = await surreal.create>( - new RecordId("person", 1), - { - firstname: "John", - lastname: "Doe", - }, - ); - - assertEquals(single, { - id: new RecordId("person", 1), - firstname: "John", - lastname: "Doe", - }, "single"); - - const multiple = await surreal.create( - "person", - { - id: new RecordId("person", 2), - firstname: "Mary", - lastname: "Doe", - }, - ); - - assertEquals(multiple, [{ - id: new RecordId("person", 2), - firstname: "Mary", - lastname: "Doe", - }], "multiple"); - - await surreal.close(); -}); - -Deno.test("select", async () => { - const surreal = await createSurreal(); - - const single = await surreal.select(new RecordId("person", 1)); - - assertEquals(single, { - id: new RecordId("person", 1), - firstname: "John", - lastname: "Doe", - }, "single"); - - const multiple = await surreal.select("person"); - - assertEquals(multiple, [ - { - id: new RecordId("person", 1), - firstname: "John", - lastname: "Doe", - }, - { - id: new RecordId("person", 2), - firstname: "Mary", - lastname: "Doe", - }, - ], "multiple"); - - await surreal.close(); -}); - -Deno.test("merge", async () => { - const surreal = await createSurreal(); - - const single = await surreal.merge( - new RecordId("person", 1), - { age: 20 }, - ); - - assertEquals(single, { - id: new RecordId("person", 1), - firstname: "John", - lastname: "Doe", - age: 20, - }, "single"); - - const multiple = await surreal.merge("person", { age: 25 }); - - assertEquals(multiple, [ - { - id: new RecordId("person", 1), - firstname: "John", - lastname: "Doe", - age: 25, - }, - { - id: new RecordId("person", 2), - firstname: "Mary", - lastname: "Doe", - age: 25, - }, - ], "multiple"); - - await surreal.close(); -}); - -Deno.test("update", async () => { - const surreal = await createSurreal(); - - const single = await surreal.update>( - new RecordId("person", 1), - { - firstname: "John", - lastname: "Doe", - }, - ); - - assertEquals(single, { - id: new RecordId("person", 1), - firstname: "John", - lastname: "Doe", - }, "single"); - - const multiple = await surreal.update>( - "person", - { - firstname: "Mary", - lastname: "Doe", - }, - ); - - assertEquals(multiple, [ - { - id: new RecordId("person", 1), - firstname: "Mary", - lastname: "Doe", - }, - { - id: new RecordId("person", 2), - firstname: "Mary", - lastname: "Doe", - }, - ], "multiple"); - - await surreal.close(); -}); - -Deno.test("patch", async () => { - const surreal = await createSurreal(); - - const single = await surreal.patch( - new RecordId("person", 1), - [{ op: "replace", path: "/firstname", value: "John" }], - ); - - assertEquals(single, { - id: new RecordId("person", 1), - firstname: "John", - lastname: "Doe", - }, "single"); - - const multiple = await surreal.patch( - "person", - [{ op: "replace", path: "/age", value: 30 }], - ); - - assertEquals(multiple, [ - { - id: new RecordId("person", 1), - firstname: "John", - lastname: "Doe", - age: 30, - }, - { - id: new RecordId("person", 2), - firstname: "Mary", - lastname: "Doe", - age: 30, - }, - ], "multiple"); - - const singleDiff = await surreal.patch( - new RecordId("person", 1), - [{ op: "replace", path: "/age", value: 25 }], - true, - ); - - assertEquals(singleDiff, [ - { op: "replace", path: "/age", value: 25 }, - ], "singleDiff"); - - const multipleDiff = await surreal.patch( - "person", - [{ op: "replace", path: "/age", value: 20 }], - true, - ); - - assertEquals(multipleDiff, [ - [{ op: "replace", path: "/age", value: 20 }], - [{ op: "replace", path: "/age", value: 20 }], - ], "multipleDiff"); - - await surreal.close(); -}); - -Deno.test("delete", async () => { - const surreal = await createSurreal(); - - const single = await surreal.delete(new RecordId("person", 1)); - - assertEquals(single, { - id: new RecordId("person", 1), - firstname: "John", - lastname: "Doe", - age: 20, - }, "single"); - - const multiple = await surreal.delete("person"); - - assertEquals(multiple, [ - { - id: new RecordId("person", 2), - firstname: "Mary", - lastname: "Doe", - age: 20, - }, - ], "multiple"); - - await surreal.close(); -}); - -Deno.test("relate", async () => { - const surreal = await createSurreal(); - - const single = await surreal.relate( - new RecordId("edge", "in"), - new RecordId("graph", 1), - new RecordId("edge", "out"), - { - num: 123, - }, - ); - - assertEquals(single, { - id: new RecordId("graph", 1), - in: new RecordId("edge", "in"), - out: new RecordId("edge", "out"), - num: 123, - }, "single"); - - const multiple = await surreal.relate( - new RecordId("edge", "in"), - "graph", - new RecordId("edge", "out"), - { - id: new RecordId("graph", 2), - num: 456, - }, - ); - - assertEquals(multiple, [ - { - id: new RecordId("graph", 2), - in: new RecordId("edge", "in"), - out: new RecordId("edge", "out"), - num: 456, - }, - ], "multiple"); - - await surreal.close(); -}); - -Deno.test("run", async () => { - const surreal = await createSurreal(); - - const res = await surreal.run("array::add", [[1, 2], 3]); - assertEquals(res, [1, 2, 3]); - - await surreal.close(); -}); - -Deno.test("query", async () => { - const surreal = await createSurreal(); - - const input = { - // Native - string: "Hello World!", - number: 123, - float: 123.456, - true: true, - false: false, - null: null, - undefined: undefined, - array: [123], - object: { num: 456 }, - date: new Date(), - - // Custom - // Decimals are currently bugged on SurrealDB side of decoding - // decimal: new Decimal("123.456"), - rid: new RecordId("some-custom", [ - "recordid", - { with_an: "object" }, - undefined, - ]), - uuidv4: UUID.parse(uuidv4()), - uuidv7: UUID.parse(uuidv7()), - duration: new Duration("1w1d1h1s1ms"), - geometries: new GeometryCollection([ - new GeometryPoint([1, 2]), - new GeometryMultiPolygon([ - new GeometryPolygon([ - new GeometryLine([ - new GeometryPoint([1, 2]), - new GeometryPoint([3, 4]), - ]), - new GeometryLine([ - new GeometryPoint([5, 6]), - new GeometryPoint([7, 8]), - ]), - ]), - ]), - ]), - }; - - const [output] = await surreal.query<[typeof input]>( - /* surql */ `$input`, - { input }, - ); - - assertEquals(output, input, "datatypes"); - - await surreal.close(); -}); - -Deno.test("record id bigint", async () => { - const surreal = await createSurreal(); - - const [output] = await surreal.query<[{ id: RecordId }]>( - /* surql */ `CREATE ONLY $id`, - { - id: new RecordId("person", 90071992547409915n), - }, - ); - - assertEquals(output.id.tb, "person"); - assertEquals(output.id.id, 90071992547409915n); - - await surreal.close(); -}); - -Deno.test("string record id", async () => { - const surreal = await createSurreal(); - - const [output] = await surreal.query<[{ id: RecordId }]>( - /* surql */ `CREATE ONLY $id`, - { - id: new StringRecordId("person:123"), - }, - ); - - assertEquals(output.id.tb, "person"); - assertEquals(output.id.id, 123); - - await surreal.close(); -}); - -Deno.test("table", async () => { - const surreal = await createSurreal(); - - const [output] = await surreal.query<[Table]>( - /* surql */ `RETURN type::table($table)`, - { - table: "person", - }, - ); - - assertEquals(output.tb, "person"); - - await surreal.close(); -}); diff --git a/tests/integration/ws.ts b/tests/integration/ws.ts deleted file mode 100644 index f36b6af7..00000000 --- a/tests/integration/ws.ts +++ /dev/null @@ -1,2 +0,0 @@ -globalThis.protocol = "ws"; -import "./mod.ts"; diff --git a/tests/unit/__snapshots__/cbor.test.ts.snap b/tests/unit/__snapshots__/cbor.test.ts.snap new file mode 100644 index 00000000..cb9f6e50 --- /dev/null +++ b/tests/unit/__snapshots__/cbor.test.ts.snap @@ -0,0 +1,406 @@ + + +exports[`encode basic: positive integer 1`] = ` +ArrayBuffer [ + 24, + 123, +] +`; + +exports[`encode basic: negative integer 1`] = ` +ArrayBuffer [ + 56, + 122, +] +`; + +exports[`encode basic: positive float 1`] = ` +ArrayBuffer [ + 251, + 64, + 94, + 221, + 47, + 26, + 159, + 190, + 119, +] +`; + +exports[`encode basic: negative float 1`] = ` +ArrayBuffer [ + 251, + 192, + 94, + 221, + 47, + 26, + 159, + 190, + 119, +] +`; + +exports[`encode basic: positive bigint 1`] = ` +ArrayBuffer [ + 27, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, +] +`; + +exports[`encode basic: negative bigint 1`] = ` +ArrayBuffer [ + 59, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, +] +`; + +exports[`encode basic: string 1`] = ` +ArrayBuffer [ + 109, + 72, + 101, + 108, + 108, + 111, + 32, + 10, + 87, + 111, + 114, + 108, + 100, + 33, +] +`; + +exports[`encode basic: undefined 1`] = ` +ArrayBuffer [ + 247, +] +`; + +exports[`encode basic: null 1`] = ` +ArrayBuffer [ + 246, +] +`; + +exports[`encode basic: true 1`] = ` +ArrayBuffer [ + 245, +] +`; + +exports[`encode basic: false 1`] = ` +ArrayBuffer [ + 244, +] +`; + +exports[`encode basic: map 1`] = ` +ArrayBuffer [ + 161, + 99, + 107, + 101, + 121, + 101, + 118, + 97, + 108, + 117, + 101, +] +`; + +exports[`encode basic: object 1`] = ` +ArrayBuffer [ + 161, + 99, + 107, + 101, + 121, + 101, + 118, + 97, + 108, + 117, + 101, +] +`; + +exports[`encode basic: array 1`] = ` +ArrayBuffer [ + 130, + 24, + 123, + 99, + 97, + 98, + 99, +] +`; + +exports[`encode basic: uint8array 1`] = ` +ArrayBuffer [ + 67, + 1, + 2, + 3, +] +`; + +exports[`encode basic: arraybuffer 1`] = ` +ArrayBuffer [ + 67, + 1, + 2, + 3, +] +`; + +exports[`encode/decode: encoded input 1`] = ` +ArrayBuffer [ + 175, + 102, + 112, + 111, + 115, + 105, + 110, + 116, + 24, + 123, + 102, + 110, + 101, + 103, + 105, + 110, + 116, + 56, + 122, + 102, + 112, + 111, + 115, + 102, + 108, + 111, + 251, + 64, + 94, + 221, + 47, + 26, + 159, + 190, + 119, + 102, + 110, + 101, + 103, + 102, + 108, + 111, + 251, + 192, + 94, + 221, + 47, + 26, + 159, + 190, + 119, + 102, + 112, + 111, + 115, + 98, + 105, + 103, + 27, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 102, + 110, + 101, + 103, + 98, + 105, + 103, + 59, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 102, + 115, + 116, + 114, + 105, + 110, + 103, + 108, + 72, + 101, + 108, + 108, + 111, + 32, + 87, + 111, + 114, + 108, + 100, + 33, + 105, + 117, + 110, + 100, + 101, + 102, + 105, + 110, + 101, + 100, + 247, + 100, + 110, + 117, + 108, + 108, + 246, + 101, + 102, + 97, + 108, + 115, + 101, + 244, + 100, + 116, + 114, + 117, + 101, + 245, + 99, + 109, + 97, + 112, + 161, + 99, + 107, + 101, + 121, + 101, + 118, + 97, + 108, + 117, + 101, + 101, + 97, + 114, + 114, + 97, + 121, + 130, + 24, + 123, + 99, + 97, + 98, + 99, + 106, + 117, + 105, + 110, + 116, + 56, + 97, + 114, + 114, + 97, + 121, + 67, + 1, + 2, + 3, + 107, + 97, + 114, + 114, + 97, + 121, + 98, + 117, + 102, + 102, + 101, + 114, + 67, + 1, + 2, + 3, +] +`; + +exports[`encode/decode: decoded input 1`] = ` +{ + "array": [ + 123, + "abc", + ], + "arraybuffer": ArrayBuffer [ + 1, + 2, + 3, + ], + "false": false, + "map": { + "key": "value", + }, + "negbig": -18446744073709551616n, + "negflo": -123.456, + "negint": -123, + "null": null, + "posbig": 18446744073709551615n, + "posflo": 123.456, + "posint": 123, + "string": "Hello World!", + "true": true, + "uint8array": ArrayBuffer [ + 1, + 2, + 3, + ], + "undefined": undefined, +} +`; diff --git a/tests/unit/__snapshots__/jsonify.test.ts.snap b/tests/unit/__snapshots__/jsonify.test.ts.snap new file mode 100644 index 00000000..7755393d --- /dev/null +++ b/tests/unit/__snapshots__/jsonify.test.ts.snap @@ -0,0 +1,102 @@ +// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[`jsonify matches snapshot 1`] = ` +{ + "date": "2024-05-06T17:44:57.085Z", + "dec": "3.333333", + "dur": "1d2h", + "false": false, + "float": 123.456, + "geo": { + "geometries": [ + { + "coordinates": [ + 1, + 2, + ], + "type": "Point", + }, + { + "coordinates": [ + [ + [ + [ + 1, + 2, + ], + [ + 3, + 4, + ], + [ + 1, + 2, + ], + ], + [ + [ + 5, + 6, + ], + [ + 7, + 8, + ], + [ + 5, + 6, + ], + ], + ], + ], + "type": "MultiPolygon", + }, + { + "coordinates": [ + [ + [ + 1, + 2, + ], + [ + 3, + 4, + ], + [ + 1, + 2, + ], + ], + [ + [ + 5, + 6, + ], + [ + 7, + 8, + ], + [ + 5, + 6, + ], + ], + ], + "type": "Polygon", + }, + ], + "type": "GeometryCollection", + }, + "id_almost_a_number": "⟨some:thing⟩:1e23", + "id_is_a_number": "⟨some:thing⟩:123", + "id_looks_like_number": "⟨some:thing⟩:⟨123⟩", + "null": null, + "num": 123, + "rid": "⟨some:thing⟩:under_score", + "str_rid": "⟨some:thing⟩:under_score", + "string": "I am a string", + "tb": "some super _ cool table", + "true": true, + "uuid": "92b84bde-39c8-4b4b-92f7-626096d6c4d9", +} +`; diff --git a/tests/unit/__snapshots__/jsonify.ts.snap b/tests/unit/__snapshots__/jsonify.ts.snap deleted file mode 100644 index 4f14fae1..00000000 --- a/tests/unit/__snapshots__/jsonify.ts.snap +++ /dev/null @@ -1,86 +0,0 @@ -export const snapshot = {}; - -snapshot[`jsonify matches snapshot 1`] = ` -{ - date: "2024-05-06T17:44:57.085Z", - dec: "3.333333", - dur: "26h", - false: false, - float: 123.456, - geo: { - geometries: [ - { - coordinates: [ - "1", - "2", - ], - type: "Point", - }, - { - coordinates: [ - [ - [ - [ - "1", - "2", - ], - [ - "3", - "4", - ], - ], - [ - [ - "5", - "6", - ], - [ - "7", - "8", - ], - ], - ], - ], - type: "MultiPolygon", - }, - { - coordinates: [ - [ - [ - "1", - "2", - ], - [ - "3", - "4", - ], - ], - [ - [ - "5", - "6", - ], - [ - "7", - "8", - ], - ], - ], - type: "Polygon", - }, - ], - type: "GeometryCollection", - }, - id_almost_a_number: "⟨some:thing⟩:1e23", - id_is_a_number: "⟨some:thing⟩:123", - id_looks_like_number: "⟨some:thing⟩:⟨123⟩", - null: null, - num: 123, - rid: "⟨some:thing⟩:under_score", - str_rid: "⟨some:thing⟩:under_score", - string: "I am a string", - tb: "some super _ cool table", - true: true, - uuid: "92b84bde-39c8-4b4b-92f7-626096d6c4d9", -} -`; diff --git a/tests/unit/cbor.test.ts b/tests/unit/cbor.test.ts new file mode 100644 index 00000000..d41a5897 --- /dev/null +++ b/tests/unit/cbor.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "bun:test"; +import { + CborFillMissing, + CborInvalidMajorError, + CborPartialDisabled, + CborRangeError, + Gap, + cbor, +} from "../../src"; + +test("encode basic", () => { + expect(cbor.encode(123)).toMatchSnapshot("positive integer"); + expect(cbor.encode(-123)).toMatchSnapshot("negative integer"); + expect(cbor.encode(123.456)).toMatchSnapshot("positive float"); + expect(cbor.encode(-123.456)).toMatchSnapshot("negative float"); + expect(cbor.encode(cbor.POW_2_64 - 1n)).toMatchSnapshot("positive bigint"); + expect(cbor.encode(-cbor.POW_2_64)).toMatchSnapshot("negative bigint"); + expect(cbor.encode("Hello \nWorld!")).toMatchSnapshot("string"); + expect(cbor.encode(undefined)).toMatchSnapshot("undefined"); + expect(cbor.encode(null)).toMatchSnapshot("null"); + expect(cbor.encode(true)).toMatchSnapshot("true"); + expect(cbor.encode(false)).toMatchSnapshot("false"); + expect(cbor.encode(new Map([["key", "value"]]))).toMatchSnapshot("map"); + expect(cbor.encode({ key: "value" })).toMatchSnapshot("object"); + expect(cbor.encode([123, "abc"])).toMatchSnapshot("array"); + + const bytes = new Uint8Array([1, 2, 3]); + expect(cbor.encode(bytes)).toMatchSnapshot("uint8array"); + expect(cbor.encode(bytes.buffer)).toMatchSnapshot("arraybuffer"); +}); + +test("encode/decode", () => { + const bytes = new Uint8Array([1, 2, 3]); + const input = { + posint: 123, + negint: -123, + posflo: 123.456, + negflo: -123.456, + posbig: cbor.POW_2_64 - 1n, + negbig: -cbor.POW_2_64, + string: "Hello World!", + undefined: undefined, + null: null, + false: false, + true: true, + map: new Map([["key", "value"]]), + array: [123, "abc"], + uint8array: bytes, + arraybuffer: bytes.buffer, + }; + + const encoded = cbor.encode(input); + expect(encoded).toMatchSnapshot("encoded input"); + + const decoded = cbor.decode(encoded); + expect(decoded).toMatchSnapshot("decoded input"); +}); + +describe("infinity", () => { + test("valid bytes", () => { + const decoded = cbor.decode( + new Uint8Array([ + 95, // infinite bytes start + 65, // byte string, len 1 + 1, // Some byte + 66, // byte string, len 2 + 1, // Some byte + 2, // Some byte + 255, // break + ]).buffer, + ); + + expect(decoded).toMatchObject(new Uint8Array([1, 1, 2]).buffer); + }); + + test("invalid bytes, nested infinite bytes", () => { + const res = new Promise(() => + cbor.decode( + new Uint8Array([ + 95, // infinite bytes start + 95, // infinite bytes start (not allowed) + 65, // byte string, len 1 + 1, // Some byte + 255, // break + 255, // break + ]).buffer, + ), + ); + + expect(res).rejects.toBeInstanceOf(CborRangeError); + }); + + test("invalid bytes, no break", () => { + const res = new Promise(() => + cbor.decode( + new Uint8Array([ + 95, // infinite bytes start + 65, // byte string, len 1 + 1, // Some byte + // break (255) is missing + ]).buffer, + ), + ); + + expect(res).rejects.toBeInstanceOf(CborRangeError); + }); + + test("invalid bytes, invalid major", () => { + const res = new Promise(() => + cbor.decode( + new Uint8Array([ + 95, // infinite bytes start + 96, // text string, len 1 (invalid major) + 1, // Some byte + 255, // break + ]).buffer, + ), + ); + + expect(res).rejects.toBeInstanceOf(CborInvalidMajorError); + }); + + test("valid text", () => { + const decoded = cbor.decode( + new Uint8Array([ + 127, // infinite text start + 97, // byte string, len 1 + 97, // letter "a" + 98, // byte string, len 2 + 98, // letter "b" + 99, // letter "c" + 255, // break + ]).buffer, + ); + + expect(decoded).toMatch("abc"); + }); + + test("valid array", () => { + const decoded = cbor.decode( + new Uint8Array([ + 159, // infinite array start + 97, // byte string, len 1 + 97, // letter "a" + 98, // byte string, len 2 + 98, // letter "b" + 99, // letter "c" + 255, // break + ]).buffer, + ); + + expect(decoded).toMatchObject(["a", "bc"]); + }); + + test("valid map", () => { + const decoded = cbor.decode( + new Uint8Array([ + 191, // infinite map start + 97, // byte string, len 1 + 97, // letter "a" + 98, // byte string, len 2 + 98, // letter "b" + 99, // letter "c" + 255, // break + ]).buffer, + ); + + expect(decoded).toMatchObject({ a: "bc" }); + }); +}); + +describe("partial", () => { + test("Fails if not enabled", () => { + const res = new Promise(() => { + const gap = new Gap(); + cbor.encode({ gap }); + }); + + expect(res).rejects.toBeInstanceOf(CborPartialDisabled); + }); + + test("Fails to build if fill for gap is missing", () => { + const res = new Promise(() => { + const gap = new Gap(); + const partial = cbor.encode({ gap }, { partial: true }); + partial.build([]); + }); + + expect(res).rejects.toBeInstanceOf(CborFillMissing); + }); + + describe("Succeeds if configured correctly", () => { + const name = new Gap(); + const age = new Gap(); + const enabled = new Gap(true); + const partial = cbor.encode( + [ + "CREATE person SET name = $name, age = $age, enabled = $enabled", + { + name, + age, + enabled, + }, + ], + { partial: true }, + ); + + test("with gaps filled", () => { + const res = cbor.decode(partial.build([name.fill("John"), age.fill(30)])); + expect(res?.[1]).toStrictEqual({ + name: "John", + age: 30, + enabled: true, + }); + }); + + test("with defaults overwritten", () => { + const res = cbor.decode( + partial.build([name.fill("John"), age.fill(30), enabled.fill(false)]), + ); + + expect(res?.[1]).toStrictEqual({ + name: "John", + age: 30, + enabled: false, + }); + }); + }); +}); diff --git a/tests/unit/duration.test.ts b/tests/unit/duration.test.ts new file mode 100644 index 00000000..daa115af --- /dev/null +++ b/tests/unit/duration.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { Duration } from "../../src"; + +test("durations", () => { + expect(new Duration("1ns").toString()).toBe("1ns"); + expect(new Duration("1us").toString()).toBe("1us"); + expect(new Duration("1ms").toString()).toBe("1ms"); + expect(new Duration("1s").toString()).toBe("1s"); + expect(new Duration("1m").toString()).toBe("1m"); + expect(new Duration("1h").toString()).toBe("1h"); + expect(new Duration("1d").toString()).toBe("1d"); + expect(new Duration("1w").toString()).toBe("1w"); + + // Test that toString is always as small as possible + expect(new Duration("7d").toString()).toBe("1w"); + + const dur = new Duration("1w"); + + expect(dur.nanoseconds).toEqual(604800000000000); + expect(dur.microseconds).toEqual(604800000000); + expect(dur._milliseconds).toEqual(604800000); + expect(dur.seconds).toEqual(604800); + expect(dur.minutes).toEqual(10080); + expect(dur.hours).toEqual(168); + expect(dur.days).toEqual(7); + expect(dur.weeks).toEqual(1); + + expect(Duration.nanoseconds(604800000000000)).toMatchObject(dur); + expect(Duration.microseconds(604800000000)).toMatchObject(dur); + expect(Duration.milliseconds(604800000)).toMatchObject(dur); + expect(Duration.seconds(604800)).toMatchObject(dur); + expect(Duration.minutes(10080)).toMatchObject(dur); + expect(Duration.hours(168)).toMatchObject(dur); + expect(Duration.days(7)).toMatchObject(dur); + expect(Duration.weeks(1)).toMatchObject(dur); + + expect(dur.toCompact()).toStrictEqual([604800]); + expect(Duration.fromCompact([604800])).toMatchObject(dur); +}); diff --git a/tests/unit/isVersionSupported.test.ts b/tests/unit/isVersionSupported.test.ts new file mode 100644 index 00000000..642236cc --- /dev/null +++ b/tests/unit/isVersionSupported.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { isVersionSupported } from "../../src/util/versionCheck.ts"; + +describe("isVersionSupported", () => { + test("1.0.0 should be unsupported", () => { + expect(isVersionSupported("1.0.0")).toBe(false); + }); + + test("1.4.1 should be unsupported", () => { + expect(isVersionSupported("1.4.1")).toBe(false); + }); + + test("1.4.2 should be supported", () => { + expect(isVersionSupported("1.4.2")).toBe(true); + }); + + test("1.99.99 should be supported", () => { + expect(isVersionSupported("1.99.99")).toBe(true); + }); + + test("3.0.0 should be unsupported", () => { + expect(isVersionSupported("3.0.0")).toBe(false); + }); +}); diff --git a/tests/unit/isVersionSupported.ts b/tests/unit/isVersionSupported.ts deleted file mode 100644 index 5f553fcf..00000000 --- a/tests/unit/isVersionSupported.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { assertEquals } from "https://deno.land/std@0.223.0/assert/assert_equals.ts"; -import { isVersionSupported } from "../../src/library/versionCheck.ts"; - -Deno.test("isVersionSupported", () => { - assertEquals( - isVersionSupported("1.0.0"), - false, - "1.0.0 should be unsupported", - ); - assertEquals( - isVersionSupported("1.4.1"), - false, - "1.4.1 should be unsupported", - ); - assertEquals( - isVersionSupported("1.4.2"), - true, - "1.4.2 should be supported", - ); - assertEquals( - isVersionSupported("1.99.99"), - true, - "1.99.99 should be supported", - ); - assertEquals( - isVersionSupported("2.0.0"), - false, - "2.0.0 should be unsupported", - ); -}); diff --git a/tests/unit/jsonify.test.ts b/tests/unit/jsonify.test.ts new file mode 100644 index 00000000..c37b6bda --- /dev/null +++ b/tests/unit/jsonify.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { + Decimal, + Duration, + GeometryCollection, + GeometryLine, + GeometryMultiPolygon, + GeometryPoint, + GeometryPolygon, + RecordId, + StringRecordId, + Table, + Uuid, + jsonify, +} from "../../src"; + +test("jsonify matches snapshot", () => { + const json = jsonify({ + rid: new RecordId("some:thing", "under_score"), + id_looks_like_number: new RecordId("some:thing", "123"), + id_almost_a_number: new RecordId("some:thing", "1e23"), + id_is_a_number: new RecordId("some:thing", 123), + str_rid: new StringRecordId("⟨some:thing⟩:under_score"), + dec: new Decimal("3.333333"), + dur: new Duration("1d2h"), + geo: new GeometryCollection([ + new GeometryPoint([1, 2]), + new GeometryMultiPolygon([ + new GeometryPolygon([ + new GeometryLine([ + new GeometryPoint([1, 2]), + new GeometryPoint([3, 4]), + ]), + new GeometryLine([ + new GeometryPoint([5, 6]), + new GeometryPoint([7, 8]), + ]), + ]), + ]), + new GeometryPolygon([ + new GeometryLine([ + new GeometryPoint([1, 2]), + new GeometryPoint([3, 4]), + ]), + new GeometryLine([ + new GeometryPoint([5, 6]), + new GeometryPoint([7, 8]), + ]), + ]), + ]), + + tb: new Table("some super _ cool table"), + uuid: new Uuid("92b84bde-39c8-4b4b-92f7-626096d6c4d9"), + date: new Date("2024-05-06T17:44:57.085Z"), + undef: undefined, + null: null, + num: 123, + float: 123.456, + true: true, + false: false, + string: "I am a string", + }); + + expect(json).toMatchSnapshot(); +}); diff --git a/tests/unit/jsonify.ts b/tests/unit/jsonify.ts deleted file mode 100644 index 85379eed..00000000 --- a/tests/unit/jsonify.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { assertSnapshot } from "https://deno.land/std@0.224.0/testing/snapshot.ts"; -import { - Decimal, - Duration, - GeometryCollection, - GeometryLine, - GeometryMultiPolygon, - GeometryPoint, - GeometryPolygon, - jsonify, - RecordId, - StringRecordId, - Table, - UUID, -} from "../../mod.ts"; - -Deno.test("jsonify matches snapshot", async function (t) { - const json = jsonify( - { - rid: new RecordId("some:thing", "under_score"), - id_looks_like_number: new RecordId("some:thing", "123"), - id_almost_a_number: new RecordId("some:thing", "1e23"), - id_is_a_number: new RecordId("some:thing", 123), - str_rid: new StringRecordId("⟨some:thing⟩:under_score"), - dec: new Decimal("3.333333"), - dur: new Duration("1d2h"), - geo: new GeometryCollection([ - new GeometryPoint([1, 2]), - new GeometryMultiPolygon([ - new GeometryPolygon([ - new GeometryLine([ - new GeometryPoint([1, 2]), - new GeometryPoint([3, 4]), - ]), - new GeometryLine([ - new GeometryPoint([5, 6]), - new GeometryPoint([7, 8]), - ]), - ]), - ]), - new GeometryPolygon([ - new GeometryLine([ - new GeometryPoint([1, 2]), - new GeometryPoint([3, 4]), - ]), - new GeometryLine([ - new GeometryPoint([5, 6]), - new GeometryPoint([7, 8]), - ]), - ]), - ]), - - tb: new Table("some super _ cool table"), - uuid: UUID.parse("92b84bde-39c8-4b4b-92f7-626096d6c4d9"), - date: new Date("2024-05-06T17:44:57.085Z"), - undef: undefined, - null: null, - num: 123, - float: 123.456, - true: true, - false: false, - string: "I am a string", - }, - ); - - await assertSnapshot(t, json); -}); diff --git a/tests/unit/recordid.test.ts b/tests/unit/recordid.test.ts new file mode 100644 index 00000000..74a59826 --- /dev/null +++ b/tests/unit/recordid.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { RecordId } from "../../src"; + +describe("record ids", () => { + test("toString()", () => { + expect(new RecordId("table", 123).toString()).toBe("table:123"); + expect(new RecordId("table", "123").toString()).toBe("table:⟨123⟩"); + expect(new RecordId("table", "test").toString()).toBe("table:test"); + expect(new RecordId("table", "complex-ident").toString()).toBe( + "table:⟨complex-ident⟩", + ); + expect(new RecordId("complex-table", "complex-ident").toString()).toBe( + "⟨complex-table⟩:⟨complex-ident⟩", + ); + + // Bigint + expect(new RecordId("table", 9223372036854775807n).toString()).toBe( + "table:9223372036854775807", + ); + expect(new RecordId("table", 9223372036854775808n).toString()).toBe( + "table:⟨9223372036854775808⟩", + ); + + // Objects and arrays + expect(new RecordId("table", { city: "London" }).toString()).toBe( + 'table:{"city":"London"}', + ); + + expect(new RecordId("table", ["London"]).toString()).toBe( + 'table:["London"]', + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..92df3e65 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "emitDeclarationOnly": true, + "isolatedDeclarations": true, + "declaration": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}