diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6c1af093..5142538d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -93,64 +93,64 @@ jobs: retention-days: 1 path: packages/sdk/dist/ - build-wasm: - name: Build WASM - needs: build-sdk - runs-on: runner-amd64-2xlarge - steps: - - name: Install Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.2.21 - - - name: Code Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Install Rust - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - toolchain: stable - targets: wasm32-unknown-unknown - - - name: Cache Rust - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - shared-key: wasm - workspaces: packages/wasm - - - name: Install wasm-bindgen-cli - run: command -v wasm-bindgen && wasm-bindgen --version | grep -q 0.2.108 || cargo install -f wasm-bindgen-cli --version 0.2.108 - - - name: Install wasm-opt - run: command -v wasm-opt || cargo install wasm-opt - - - name: Cache turbo - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: .turbo/cache - key: turbo-wasm-${{ runner.os }}-${{ hashFiles('bun.lock') }}-${{ hashFiles('packages/wasm/src-rust/**', 'packages/wasm/src-ts/**', 'packages/wasm/Cargo.toml') }} - restore-keys: turbo-wasm-${{ runner.os }}-${{ hashFiles('bun.lock') }}- - - - name: Install dependencies - run: bun install - - - name: Download SDK build - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: sdk-build - path: packages/sdk/dist/ - - - name: Build WASM - run: bun run build:wasm - - - name: Upload WASM artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: wasm-build - retention-days: 30 - path: | - packages/wasm/dist/ - packages/wasm/wasm/ + # build-wasm: + # name: Build WASM + # needs: build-sdk + # runs-on: runner-amd64-2xlarge + # steps: + # - name: Install Bun + # uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + # with: + # bun-version: 1.2.21 + + # - name: Code Checkout + # uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + # - name: Install Rust + # uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + # with: + # toolchain: stable + # targets: wasm32-unknown-unknown + + # - name: Cache Rust + # uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + # with: + # shared-key: wasm + # workspaces: packages/wasm + + # - name: Install wasm-bindgen-cli + # run: command -v wasm-bindgen && wasm-bindgen --version | grep -q 0.2.108 || cargo install -f wasm-bindgen-cli --version 0.2.108 + + # - name: Install wasm-opt + # run: command -v wasm-opt || cargo install wasm-opt + + # - name: Cache turbo + # uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # with: + # path: .turbo/cache + # key: turbo-wasm-${{ runner.os }}-${{ hashFiles('bun.lock') }}-${{ hashFiles('packages/wasm/src-rust/**', 'packages/wasm/src-ts/**', 'packages/wasm/Cargo.toml') }} + # restore-keys: turbo-wasm-${{ runner.os }}-${{ hashFiles('bun.lock') }}- + + # - name: Install dependencies + # run: bun install + + # - name: Download SDK build + # uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + # with: + # name: sdk-build + # path: packages/sdk/dist/ + + # - name: Build WASM + # run: bun run build:wasm + + # - name: Upload WASM artifacts + # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + # with: + # name: wasm-build + # retention-days: 30 + # path: | + # packages/wasm/dist/ + # packages/wasm/wasm/ build-node: name: Build Node @@ -254,48 +254,48 @@ jobs: group_suite: true skip_success_summary: true - test-wasm: - name: Test WASM - needs: build-wasm - runs-on: ubuntu-latest - steps: - - name: Install Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.2.21 - - - name: Code Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Install dependencies - run: bun install - - - name: Download SDK build - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: sdk-build - path: packages/sdk/dist/ - - - name: Download WASM build - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: wasm-build - path: packages/wasm/ - - - name: Run WASM tests - run: cd packages/tests && SURREAL_BACKEND=wasm bun test --preload ./global-wasm.ts --reporter=junit --reporter-outfile=junit.xml - - - name: Publish Test Report - uses: mikepenz/action-junit-report@49b2ca06f62aa7ef83ae6769a2179271e160d8e4 # v6.3.1 - if: success() || failure() - with: - report_paths: packages/tests/junit.xml - check_name: Test Report (WASM) - detailed_summary: true - include_passed: false - include_skipped: false - group_suite: true - skip_success_summary: true + # test-wasm: + # name: Test WASM + # needs: build-wasm + # runs-on: ubuntu-latest + # steps: + # - name: Install Bun + # uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + # with: + # bun-version: 1.2.21 + + # - name: Code Checkout + # uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + # - name: Install dependencies + # run: bun install + + # - name: Download SDK build + # uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + # with: + # name: sdk-build + # path: packages/sdk/dist/ + + # - name: Download WASM build + # uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + # with: + # name: wasm-build + # path: packages/wasm/ + + # - name: Run WASM tests + # run: cd packages/tests && SURREAL_BACKEND=wasm bun test --preload ./global-wasm.ts --reporter=junit --reporter-outfile=junit.xml + + # - name: Publish Test Report + # uses: mikepenz/action-junit-report@49b2ca06f62aa7ef83ae6769a2179271e160d8e4 # v6.3.1 + # if: success() || failure() + # with: + # report_paths: packages/tests/junit.xml + # check_name: Test Report (WASM) + # detailed_summary: true + # include_passed: false + # include_skipped: false + # group_suite: true + # skip_success_summary: true test-node: name: Test Node diff --git a/.github/workflows/publish-sqon.yml b/.github/workflows/publish-sqon.yml new file mode 100644 index 00000000..cb3da5d4 --- /dev/null +++ b/.github/workflows/publish-sqon.yml @@ -0,0 +1,106 @@ +name: Publish SQON + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Dry run only (no actual publishing)" + required: false + default: false + type: boolean + continue: + description: "Continue publishing even if some packages fail" + required: false + default: false + type: boolean + +permissions: + contents: read + id-token: write + +jobs: + build-sqon: + name: Build SQON + runs-on: ubuntu-latest + steps: + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.2.21 + + - name: Code Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Install dependencies + run: bun install + + - name: Build SQON + run: bun run build:sqon + + - name: Upload SQON artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sqon-artifacts + retention-days: 1 + path: packages/sqon/dist/ + + dry-run-publish: + name: Dry Run Publish + runs-on: ubuntu-latest + needs: build-sqon + steps: + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.2.21 + + - name: Code Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Install dependencies + run: bun install + + - name: Download SQON artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: sqon-artifacts + path: packages/sqon/dist/ + + - name: Distribution summary + run: tree packages/sqon -I src + + - name: Dry run publish SQON + run: bun run deploy:sqon --dry-run ${{ github.event.inputs.continue == 'true' && '--continue' || '' }} + + publish: + name: Publish SQON + if: ${{ !github.event.inputs.dry_run || github.event.inputs.dry_run == 'false' }} + runs-on: ubuntu-latest + needs: + - build-sqon + - dry-run-publish + steps: + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.2.21 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Code Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Install dependencies + run: bun install + + - name: Download SQON artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: sqon-artifacts + path: packages/sqon/dist/ + + - name: Publish SQON + run: bun run deploy:sqon ${{ github.event.inputs.continue == 'true' && '--continue' || '' }} diff --git a/bun.lock b/bun.lock index 02283e6c..41ad3ac5 100644 --- a/bun.lock +++ b/bun.lock @@ -39,22 +39,21 @@ }, "packages/node": { "name": "@surrealdb/node", - "version": "2.6.1", + "version": "3.0.3", "devDependencies": { "@napi-rs/cli": "^3.1.5", "dedent": "^1.7.0", - "surrealdb": "^2.0.0-0", + "surrealdb": "^2.0.1", }, "peerDependencies": { - "surrealdb": "^2.0.0-0", + "surrealdb": "^2.0.1", }, }, "packages/sdk": { "name": "surrealdb", - "version": "2.0.0-beta.1", + "version": "2.0.3", "dependencies": { - "@surrealdb/cbor": "2.0.0-alpha.4", - "uuidv7": "^1.0.1", + "@surrealdb/sqon": "workspace:*", }, "devDependencies": { "@types/bun": "^1.2.21", @@ -66,11 +65,27 @@ "typescript": "^5.0.0", }, }, + "packages/sqon": { + "name": "@surrealdb/sqon", + "version": "1.0.0", + "dependencies": { + "@surrealdb/cbor": "2.0.0-alpha.4", + "uuidv7": "^1.0.1", + }, + "devDependencies": { + "@types/bun": "^1.2.21", + }, + "peerDependencies": { + "tslib": "^2.6.3", + "typescript": "^5.0.0", + }, + }, "packages/tests": { "name": "@surrealdb/tests", "dependencies": { "@surrealdb/cbor": "2.0.0-alpha.4", "@surrealdb/node": "workspace:*", + "@surrealdb/sqon": "workspace:*", "@surrealdb/wasm": "workspace:*", "get-port": "^7.1.0", "surrealdb": "workspace:*", @@ -78,12 +93,12 @@ }, "packages/wasm": { "name": "@surrealdb/wasm", - "version": "2.6.1", + "version": "3.0.3", "devDependencies": { - "surrealdb": "^2.0.0-0", + "surrealdb": "^2.0.1", }, "peerDependencies": { - "surrealdb": "^2.0.0-0", + "surrealdb": "^2.0.1", }, }, }, @@ -412,6 +427,8 @@ "@surrealdb/node-demo": ["@surrealdb/node-demo@workspace:demo/node"], + "@surrealdb/sqon": ["@surrealdb/sqon@workspace:packages/sqon"], + "@surrealdb/tests": ["@surrealdb/tests@workspace:packages/tests"], "@surrealdb/wasm": ["@surrealdb/wasm@workspace:packages/wasm"], diff --git a/package.json b/package.json index 14d67cfc..f6b91a9d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "build": "turbo build", "build:sdk": "turbo build --filter=surrealdb", + "build:sqon": "turbo build --filter=@surrealdb/sqon", "build:wasm": "turbo build --filter=@surrealdb/wasm", "build:node": "turbo build --filter=@surrealdb/node --", "test": "bun run build:sdk && cd packages/tests && bun test --preload ./global.ts", @@ -15,7 +16,8 @@ "test:node": "bun run build:sdk && bun run build:node && cd packages/tests && SURREAL_BACKEND=node bun test --preload ./global-node.ts", "sync": "turbo sync", "deploy:sdk": "turbo deploy --filter surrealdb --", - "deploy:surreal": "turbo deploy --filter '@surrealdb/*' --", + "deploy:sqon": "turbo deploy --filter @surrealdb/sqon --", + "deploy:surreal": "turbo deploy --filter '@surrealdb/node' --filter '@surrealdb/wasm' --", "validate:versions": "bun run scripts/validate-versions.ts", "qc": "biome check .", "qa": "biome check . --write", diff --git a/packages/node/src-ts/engine.ts b/packages/node/src-ts/engine.ts index 27947721..e80dcd99 100644 --- a/packages/node/src-ts/engine.ts +++ b/packages/node/src-ts/engine.ts @@ -18,6 +18,7 @@ import { type Uuid, } from "surrealdb"; import { type ConnectionOptions, type NotificationReceiver, SurrealNodeEngine } from "../napi"; +import { wrapSqonError } from "./wrap-sqon-error"; type LiveChannels = Record; @@ -108,10 +109,12 @@ export class NodeEngine extends RpcEngine implements SurrealEngine { } const id = this._context.uniqueId(); - const payload = this._context.codecs.cbor.encode({ id, ...request }); + const payload = wrapSqonError(() => this._context.codecs.cbor.encode({ id, ...request })); const response = await this.#engine.execute(payload); - const decoded = this._context.codecs.cbor.decode>(response); + const decoded = wrapSqonError(() => + this._context.codecs.cbor.decode>(response), + ); if (decoded && typeof decoded === "object" && "error" in decoded) { throw parseRpcError( @@ -163,7 +166,7 @@ export class NodeEngine extends RpcEngine implements SurrealEngine { throw new ConnectionUnavailableError(); } - const payload = this._context.codecs.cbor.encode(options); + const payload = wrapSqonError(() => this._context.codecs.cbor.encode(options)); const sql = await this.#engine.export(payload); return new Response(sql); @@ -187,7 +190,9 @@ export class NodeEngine extends RpcEngine implements SurrealEngine { break; // Channel closed } - const payload = this._context.codecs.cbor.decode(value); + const payload = wrapSqonError(() => + this._context.codecs.cbor.decode(value), + ); if (payload.id) { this.#subscriptions.publish(payload.id.toString(), { diff --git a/packages/node/src-ts/wrap-sqon-error.ts b/packages/node/src-ts/wrap-sqon-error.ts new file mode 100644 index 00000000..b963aa0f --- /dev/null +++ b/packages/node/src-ts/wrap-sqon-error.ts @@ -0,0 +1,16 @@ +import { SqonError, SurrealSqonError } from "surrealdb"; + +/** + * Execute a function and wrap any thrown {@link SqonError} in a {@link SurrealSqonError}. + */ +export function wrapSqonError(fn: () => T): T { + try { + return fn(); + } catch (error) { + if (error instanceof SqonError) { + throw new SurrealSqonError(error); + } + + throw error; + } +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index c193d776..1ef7652a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -29,8 +29,7 @@ "tslib": "^2.6.3" }, "dependencies": { - "@surrealdb/cbor": "2.0.0-alpha.4", - "uuidv7": "^1.0.1" + "@surrealdb/sqon": "workspace:*" }, "types": "./dist/surrealdb.d.ts", "main": "./dist/surrealdb.mjs", diff --git a/packages/sdk/src/api/api.ts b/packages/sdk/src/api/api.ts index 0ea5aea3..f0d9f36b 100644 --- a/packages/sdk/src/api/api.ts +++ b/packages/sdk/src/api/api.ts @@ -1,9 +1,9 @@ +import type { Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { normalizePath } from "../internal/normalize-path"; import { ApiPromise } from "../query/api"; import type { Session } from "../types"; import { Features } from "../utils"; -import type { Uuid } from "../value"; /** * The request information for an api request. diff --git a/packages/sdk/src/api/queryable.ts b/packages/sdk/src/api/queryable.ts index 4cf9efed..9b84f82a 100644 --- a/packages/sdk/src/api/queryable.ts +++ b/packages/sdk/src/api/queryable.ts @@ -1,3 +1,4 @@ +import { type RecordId, type RecordIdRange, Table, type Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { AuthPromise, @@ -15,7 +16,6 @@ import { } from "../query"; import type { AnyRecordId, LiveResource, RecordResult, Session, Values } from "../types"; import { BoundQuery } from "../utils"; -import { type RecordId, type RecordIdRange, Table, type Uuid } from "../value"; import { type DefaultPaths, SurrealApi } from "./api"; /** diff --git a/packages/sdk/src/api/surreal.ts b/packages/sdk/src/api/surreal.ts index 960bc219..39928d9c 100644 --- a/packages/sdk/src/api/surreal.ts +++ b/packages/sdk/src/api/surreal.ts @@ -1,7 +1,6 @@ -import { CborCodec } from "../cbor"; +import { CborCodec, FlatBufferCodec, JsonCodec, type Uuid } from "@surrealdb/sqon"; import { ConnectionController } from "../controller"; import { UnavailableFeatureError, UnsupportedFeatureError } from "../errors"; -import { FlatBufferCodec } from "../flatbuffer/codec"; import type { Feature } from "../internal/feature"; import { getIncrementalID } from "../internal/get-incremental-id"; import { parseEndpoint } from "../internal/http"; @@ -15,7 +14,6 @@ import type { VersionInfo, } from "../types"; import { Publisher } from "../utils/publisher"; -import type { Uuid } from "../value"; import { ExportModelPromise, ExportPromise } from "./export"; import { type SessionEvents, SurrealSession } from "./session"; @@ -102,6 +100,7 @@ export class Surreal extends SurrealSession implements EventPublisher void; diff --git a/packages/sdk/src/engine/http.ts b/packages/sdk/src/engine/http.ts index a5547b81..9e9cbdbd 100644 --- a/packages/sdk/src/engine/http.ts +++ b/packages/sdk/src/engine/http.ts @@ -1,12 +1,14 @@ import { ConnectionUnavailableError, MissingNamespaceDatabaseError, + SurrealSqonError, UnexpectedServerResponseError, UnsupportedFeatureError, } from "../errors"; import { getSessionFromState } from "../internal/get-session-from-state"; import { fetchSurreal } from "../internal/http"; import { parseRpcError } from "../internal/parse-error"; +import { wrapSqonError } from "../internal/wrap-sqon-error"; import type { LiveMessage } from "../types/live"; import type { RpcRequest, RpcResponse } from "../types/rpc"; import type { ConnectionState, EngineEvents, SurrealEngine } from "../types/surreal"; @@ -117,10 +119,14 @@ export class HttpEngine extends RpcEngine implements SurrealEngine { let response: RpcResponse; try { - response = this._context.codecs.cbor.decode>( - new Uint8Array(buffer), + response = wrapSqonError(() => + this._context.codecs.cbor.decode>(new Uint8Array(buffer)), ); } catch (error) { + if (error instanceof SurrealSqonError) { + throw error; + } + throw new UnexpectedServerResponseError(error); } diff --git a/packages/sdk/src/engine/rpc.ts b/packages/sdk/src/engine/rpc.ts index 55d50917..0a08982b 100644 --- a/packages/sdk/src/engine/rpc.ts +++ b/packages/sdk/src/engine/rpc.ts @@ -1,3 +1,4 @@ +import { Duration, type Uuid } from "@surrealdb/sqon"; import { ConnectionUnavailableError, UnexpectedServerResponseError } from "../errors"; import { buildRpcAuth } from "../internal/build-rpc-auth"; import { getSessionFromState } from "../internal/get-session-from-state"; @@ -23,7 +24,6 @@ import type { VersionInfo, } from "../types"; import type { BoundQuery } from "../utils"; -import { Duration, type Uuid } from "../value"; /** * JSON-based engines implement the SurrealDB v1 protocol, which uses diff --git a/packages/sdk/src/engine/websocket.ts b/packages/sdk/src/engine/websocket.ts index 0d860ed1..26a3ede8 100644 --- a/packages/sdk/src/engine/websocket.ts +++ b/packages/sdk/src/engine/websocket.ts @@ -1,3 +1,4 @@ +import { RecordId, Uuid } from "@surrealdb/sqon"; import { CallTerminatedError, ConnectionUnavailableError, @@ -6,13 +7,13 @@ import { UnexpectedServerResponseError, } from "../errors"; import { parseRpcError } from "../internal/parse-error"; +import { wrapSqonError } from "../internal/wrap-sqon-error"; import type { LiveAction, LiveMessage, RpcRequest, RpcResponse } from "../types"; import { LIVE_ACTIONS } from "../types/live"; import type { ConnectionState, EngineEvents, SurrealEngine } from "../types/surreal"; import { Features } from "../utils"; import { ChannelIterator } from "../utils/channel-iterator"; import { Publisher } from "../utils/publisher"; -import { RecordId, Uuid } from "../value"; import { RpcEngine } from "./rpc"; type Interval = Parameters[0]; @@ -137,7 +138,9 @@ export class WebSocketEngine extends RpcEngine implements SurrealEngine { ready(): void { for (const { request } of this.#calls.values()) { - this.#socket?.send(new Uint8Array(this._context.codecs.cbor.encode(request))); + this.#socket?.send( + new Uint8Array(wrapSqonError(() => this._context.codecs.cbor.encode(request))), + ); } } @@ -158,7 +161,9 @@ export class WebSocketEngine extends RpcEngine implements SurrealEngine { }; this.#calls.set(id, call as Call); - this.#socket?.send(new Uint8Array(this._context.codecs.cbor.encode(call.request))); + this.#socket?.send( + new Uint8Array(wrapSqonError(() => this._context.codecs.cbor.encode(call.request))), + ); }); } @@ -238,7 +243,9 @@ export class WebSocketEngine extends RpcEngine implements SurrealEngine { socket.addEventListener("message", ({ data }) => { try { const buffer = this.parseBuffer(data); - const decoded = this._context.codecs.cbor.decode(buffer); + const decoded = wrapSqonError(() => + this._context.codecs.cbor.decode(buffer), + ); if ( typeof decoded === "object" && diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 8c370d8a..d75b2706 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -1,8 +1,12 @@ +import type { SqonError } from "@surrealdb/sqon"; import type { Feature } from "./internal/feature"; import type { ApiResponse } from "./query/api"; import type { Session } from "./types"; import { markSymbol, SERVER_ERROR_SYMBOL, SURREAL_ERROR_SYMBOL } from "./utils/symbols"; +/** + * The base error class for all SurrealDB SDK errors. + */ export class SurrealError extends Error { constructor(message?: string, options?: ErrorOptions) { super(message, options); @@ -10,6 +14,20 @@ export class SurrealError extends Error { } } +/** + * Thrown when a SQON value or codec operation fails within the SDK. + */ +export class SurrealSqonError extends SurrealError { + override name = "SurrealSqonError"; + + readonly inner: SqonError; + + constructor(inner: SqonError) { + super(inner.message, { cause: inner }); + this.inner = inner; + } +} + /** * Thrown when a call has been terminated because the connection was closed */ @@ -611,21 +629,6 @@ export class PublishError extends SurrealError { } } -/** - * Thrown when a parsed date or datetime is invalid - */ -export class InvalidDateError extends SurrealError { - override name = "InvalidDateError"; - - constructor(dateOrMessage: Date | string) { - if (typeof dateOrMessage === "string") { - super(dateOrMessage); - } else { - super(`The provided date is invalid: ${dateOrMessage}`); - } - } -} - /** * Thrown when a feature is not supported by the current engine */ @@ -690,37 +693,3 @@ export class UnsuccessfulApiError extends SurrealError { this.response = response; } } - -// =========================================================== // -// // -// Value Validation Errors // -// // -// =========================================================== // - -/** - * Thrown when a RecordId or RecordIdRange is constructed with invalid parts - */ -export class InvalidRecordIdError extends SurrealError { - override name = "InvalidRecordIdError"; -} - -/** - * Thrown when a Duration string cannot be parsed or a duration operation is invalid - */ -export class InvalidDurationError extends SurrealError { - override name = "InvalidDurationError"; -} - -/** - * Thrown when a Decimal operation fails (division by zero, invalid input, etc.) - */ -export class InvalidDecimalError extends SurrealError { - override name = "InvalidDecimalError"; -} - -/** - * Thrown when a Table or StringRecordId is constructed with an invalid value - */ -export class InvalidTableError extends SurrealError { - override name = "InvalidTableError"; -} diff --git a/packages/sdk/src/flatbuffer/codec.ts b/packages/sdk/src/flatbuffer/codec.ts deleted file mode 100644 index 5057bced..00000000 --- a/packages/sdk/src/flatbuffer/codec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SurrealError } from "../errors"; -import type { CodecOptions, ValueCodec } from "../types"; - -/** - * A class used to encode and decode SurrealQL values using FlatBuffers - */ -export class FlatBufferCodec implements ValueCodec { - // biome-ignore lint/correctness/noUnusedPrivateClassMembers: Todo - #options: CodecOptions; - - constructor(options: CodecOptions) { - this.#options = options; - } - - encode(_data: T): Uint8Array { - throw new SurrealError("FlatBuffer encoding is not supported in this version"); - } - - decode(_data: Uint8Array): T { - throw new SurrealError("FlatBuffer decoding is not supported in this version"); - } -} diff --git a/packages/sdk/src/flatbuffer/index.ts b/packages/sdk/src/flatbuffer/index.ts deleted file mode 100644 index f1c0eae6..00000000 --- a/packages/sdk/src/flatbuffer/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { SurrealError } from "../errors"; - -/** - * Recursively encode any supported SurrealQL value into a binary FlatBuffer representation - * - * @param data - The input value - * @returns FlatBuffer binary representation - */ -export function encodeFlatBuffer(_data: T): Uint8Array { - throw new SurrealError("Flat buffer encoding is not supported in this version"); -} - -/** - * Decode a FlatBuffer encoded SurrealQL value into object representation - * - * @param data - The encoded SurrealQL value - * @returns The parsed SurrealQL value - */ -export function decodeFlatBuffer(_data: Uint8Array): T { - throw new SurrealError("Flat buffer decoding is not supported in this version"); -} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index eb5ba1d5..a3ce8910 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,8 +1,7 @@ +export * from "@surrealdb/sqon"; export * from "./api"; -export * from "./cbor"; export * from "./engine"; export * from "./errors"; export { parseRpcError, type RpcErrorCause, type RpcErrorObject } from "./internal/parse-error"; export * from "./types"; export * from "./utils"; -export * from "./value"; diff --git a/packages/sdk/src/internal/http.ts b/packages/sdk/src/internal/http.ts index 8c0ed8b4..a6bef4bf 100644 --- a/packages/sdk/src/internal/http.ts +++ b/packages/sdk/src/internal/http.ts @@ -1,5 +1,6 @@ import { HttpConnectionError } from "../errors"; import type { ConnectionSession, ConnectionState, DriverContext } from "../types/surreal"; +import { wrapSqonError } from "./wrap-sqon-error"; export interface FetchSurrealOptions { body?: unknown; @@ -78,5 +79,5 @@ function encodeBody(context: DriverContext, body?: unknown): BodyInit | undefine return body; } - return body ? new Uint8Array(context.codecs.cbor.encode(body)) : undefined; + return body ? new Uint8Array(wrapSqonError(() => context.codecs.cbor.encode(body))) : undefined; } diff --git a/packages/sdk/src/internal/internal-expressions.ts b/packages/sdk/src/internal/internal-expressions.ts index 45a8b0da..85dab150 100644 --- a/packages/sdk/src/internal/internal-expressions.ts +++ b/packages/sdk/src/internal/internal-expressions.ts @@ -1,6 +1,6 @@ +import { Duration, RecordId, StringRecordId } from "@surrealdb/sqon"; import { ExpressionError } from "../errors"; import type { Expr, Output } from "../types"; -import { Duration, RecordId, StringRecordId } from "../value"; const OUTPUTS: Map = new Map([ ["null", "null"], diff --git a/packages/sdk/src/internal/maybe-jsonify.ts b/packages/sdk/src/internal/maybe-jsonify.ts index a6e14ffa..78adce43 100644 --- a/packages/sdk/src/internal/maybe-jsonify.ts +++ b/packages/sdk/src/internal/maybe-jsonify.ts @@ -1,4 +1,4 @@ -import { type Jsonify, jsonify } from "../utils"; +import { type Jsonify, jsonify } from "@surrealdb/sqon"; export type MaybeJsonify = J extends true ? Jsonify : T; diff --git a/packages/sdk/src/internal/validation.ts b/packages/sdk/src/internal/validation.ts index 522fa4bf..6f930740 100644 --- a/packages/sdk/src/internal/validation.ts +++ b/packages/sdk/src/internal/validation.ts @@ -1,42 +1,5 @@ +import { RecordId, StringRecordId } from "@surrealdb/sqon"; import type { AnyRecordId, Expr } from "../types"; -import { type Bound, BoundExcluded, BoundIncluded } from "../utils/range"; -import { RecordId, type RecordIdValue, StringRecordId, Table, Uuid } from "../value"; - -export function isValidIdPart(v: unknown): v is RecordIdValue { - if (v instanceof Uuid) return true; - - switch (typeof v) { - case "string": - case "number": - case "bigint": - return true; - case "object": - if (v === null) return false; - if (Array.isArray(v)) return true; - return isPlainObject(v); - default: - return false; - } -} - -export function isPlainObject(v: unknown): v is Record { - if (v === null || typeof v !== "object") return false; - const proto = Object.getPrototypeOf(v); - return proto === null || proto === Object.prototype; -} - -export function isValidIdBound(bound: unknown): bound is Bound { - return bound instanceof BoundIncluded || bound instanceof BoundExcluded - ? isValidIdPart( - (bound as unknown as BoundIncluded | BoundExcluded) - .value, - ) - : true; -} - -export function isValidTable(tb: unknown): tb is string | Table { - return tb instanceof Table || typeof tb === "string"; -} export function isAnyRecordId(value: unknown): value is AnyRecordId { return value instanceof RecordId || value instanceof StringRecordId; diff --git a/packages/sdk/src/internal/wrap-sqon-error.ts b/packages/sdk/src/internal/wrap-sqon-error.ts new file mode 100644 index 00000000..04a6df32 --- /dev/null +++ b/packages/sdk/src/internal/wrap-sqon-error.ts @@ -0,0 +1,17 @@ +import { SqonError } from "@surrealdb/sqon"; +import { SurrealSqonError } from "../errors"; + +/** + * Execute a function and wrap any thrown {@link SqonError} in a {@link SurrealSqonError}. + */ +export function wrapSqonError(fn: () => T): T { + try { + return fn(); + } catch (error) { + if (error instanceof SqonError) { + throw new SurrealSqonError(error); + } + + throw error; + } +} diff --git a/packages/sdk/src/query/api.ts b/packages/sdk/src/query/api.ts index feb6f104..9b661422 100644 --- a/packages/sdk/src/query/api.ts +++ b/packages/sdk/src/query/api.ts @@ -1,3 +1,4 @@ +import type { Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { SurrealError, UnsuccessfulApiError } from "../errors"; import { DispatchedPromise } from "../internal/dispatched-promise"; @@ -5,7 +6,6 @@ import type { MaybeJsonify } from "../internal/maybe-jsonify"; import type { Session } from "../types"; import { type BoundQuery, surql } from "../utils"; import type { Frame } from "../utils/frame"; -import type { Uuid } from "../value"; import { Query } from "./query"; /** diff --git a/packages/sdk/src/query/auth.ts b/packages/sdk/src/query/auth.ts index 445c09fc..6385530c 100644 --- a/packages/sdk/src/query/auth.ts +++ b/packages/sdk/src/query/auth.ts @@ -1,10 +1,10 @@ +import type { Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { DispatchedPromise } from "../internal/dispatched-promise"; import type { MaybeJsonify } from "../internal/maybe-jsonify"; import type { Session } from "../types"; import { BoundQuery } from "../utils"; import type { Frame } from "../utils/frame"; -import type { Uuid } from "../value"; import { Query } from "./query"; interface AuthOptions { diff --git a/packages/sdk/src/query/create.ts b/packages/sdk/src/query/create.ts index cc7df7af..20d44382 100644 --- a/packages/sdk/src/query/create.ts +++ b/packages/sdk/src/query/create.ts @@ -1,3 +1,4 @@ +import type { DateTime, Duration, Table, Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { DispatchedPromise } from "../internal/dispatched-promise"; import { _only, _output, _timeout } from "../internal/internal-expressions"; @@ -5,7 +6,6 @@ import type { MaybeJsonify } from "../internal/maybe-jsonify"; import type { AnyRecordId, Mutation, Output, Patch, Session, Values } from "../types"; import { type BoundQuery, raw, surql } from "../utils"; import type { Frame } from "../utils/frame"; -import type { DateTime, Duration, Table, Uuid } from "../value"; import { Query } from "./query"; interface CreateOptions { diff --git a/packages/sdk/src/query/delete.ts b/packages/sdk/src/query/delete.ts index 37a0e64f..53fed1e4 100644 --- a/packages/sdk/src/query/delete.ts +++ b/packages/sdk/src/query/delete.ts @@ -1,3 +1,4 @@ +import type { DateTime, Duration, RecordIdRange, Table, Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { DispatchedPromise } from "../internal/dispatched-promise"; import { _only, _output, _timeout } from "../internal/internal-expressions"; @@ -5,7 +6,6 @@ import type { MaybeJsonify } from "../internal/maybe-jsonify"; import type { AnyRecordId, Output, Session } from "../types"; import { type BoundQuery, surql } from "../utils"; import type { Frame } from "../utils/frame"; -import type { DateTime, Duration, RecordIdRange, Table, Uuid } from "../value"; import { Query } from "./query"; interface DeleteOptions { diff --git a/packages/sdk/src/query/insert.ts b/packages/sdk/src/query/insert.ts index 9ffdf528..6826b5f5 100644 --- a/packages/sdk/src/query/insert.ts +++ b/packages/sdk/src/query/insert.ts @@ -1,3 +1,4 @@ +import type { DateTime, Duration, Table, Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { DispatchedPromise } from "../internal/dispatched-promise"; import { _output, _timeout } from "../internal/internal-expressions"; @@ -5,7 +6,6 @@ import type { MaybeJsonify } from "../internal/maybe-jsonify"; import type { Output, Session } from "../types"; import { type BoundQuery, surql } from "../utils"; import type { Frame } from "../utils/frame"; -import type { DateTime, Duration, Table, Uuid } from "../value"; import { Query } from "./query"; interface InsertOptions { diff --git a/packages/sdk/src/query/live.ts b/packages/sdk/src/query/live.ts index 6b96892d..8c11062c 100644 --- a/packages/sdk/src/query/live.ts +++ b/packages/sdk/src/query/live.ts @@ -1,3 +1,4 @@ +import type { Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { DispatchedPromise } from "../internal/dispatched-promise"; import type { Expr, ExprLike, LiveResource, Session } from "../types"; @@ -8,7 +9,6 @@ import { ManagedLiveSubscription, UnmanagedLiveSubscription, } from "../utils/live"; -import type { Uuid } from "../value"; import { Query } from "./query"; interface ManagedLiveOptions { diff --git a/packages/sdk/src/query/query.ts b/packages/sdk/src/query/query.ts index 3e6ee100..46e5e0c5 100644 --- a/packages/sdk/src/query/query.ts +++ b/packages/sdk/src/query/query.ts @@ -1,10 +1,10 @@ +import type { Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { DispatchedPromise } from "../internal/dispatched-promise"; import { type MaybeJsonify, maybeJsonify } from "../internal/maybe-jsonify"; import type { QueryResponse, Session } from "../types"; import type { BoundQuery } from "../utils"; import { DoneFrame, ErrorFrame, type Frame, ValueFrame } from "../utils/frame"; -import type { Uuid } from "../value"; interface QueryOptions { query: BoundQuery; diff --git a/packages/sdk/src/query/relate.ts b/packages/sdk/src/query/relate.ts index b4322310..44a7ca5a 100644 --- a/packages/sdk/src/query/relate.ts +++ b/packages/sdk/src/query/relate.ts @@ -1,3 +1,4 @@ +import { type DateTime, type Duration, RecordId, type Table, type Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { SurrealError } from "../errors"; import { DispatchedPromise } from "../internal/dispatched-promise"; @@ -6,7 +7,6 @@ import type { MaybeJsonify } from "../internal/maybe-jsonify"; import type { AnyRecordId, Output, Session } from "../types"; import { type BoundQuery, surql } from "../utils"; import type { Frame } from "../utils/frame"; -import { type DateTime, type Duration, RecordId, type Table, type Uuid } from "../value"; import { Query } from "./query"; interface RelateOptions { diff --git a/packages/sdk/src/query/run.ts b/packages/sdk/src/query/run.ts index 7c9ebcc7..242bdad2 100644 --- a/packages/sdk/src/query/run.ts +++ b/packages/sdk/src/query/run.ts @@ -1,3 +1,4 @@ +import type { Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { ExpressionError } from "../errors"; import { DispatchedPromise } from "../internal/dispatched-promise"; @@ -5,7 +6,6 @@ import type { MaybeJsonify } from "../internal/maybe-jsonify"; import type { Session } from "../types"; import { BoundQuery, surql } from "../utils"; import type { Frame } from "../utils/frame"; -import type { Uuid } from "../value"; import { Query } from "./query"; const NAME_REGEX = /^[a-zA-Z0-9_:]+$/; diff --git a/packages/sdk/src/query/select.ts b/packages/sdk/src/query/select.ts index a9972f97..58c2c5c9 100644 --- a/packages/sdk/src/query/select.ts +++ b/packages/sdk/src/query/select.ts @@ -1,3 +1,4 @@ +import type { DateTime, Duration, RecordIdRange, Table, Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { DispatchedPromise } from "../internal/dispatched-promise"; import { _only, _timeout } from "../internal/internal-expressions"; @@ -6,7 +7,6 @@ import type { AnyRecordId, Expr, ExprLike, Session } from "../types"; import type { Field, Selection } from "../types/internal"; import { type BoundQuery, surql } from "../utils"; import type { Frame } from "../utils/frame"; -import type { DateTime, Duration, RecordIdRange, Table, Uuid } from "../value"; import { Query } from "./query"; interface SelectOptions { diff --git a/packages/sdk/src/query/update.ts b/packages/sdk/src/query/update.ts index ece3927f..36c4e93a 100644 --- a/packages/sdk/src/query/update.ts +++ b/packages/sdk/src/query/update.ts @@ -1,3 +1,4 @@ +import type { Duration, RecordIdRange, Table, Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { DispatchedPromise } from "../internal/dispatched-promise"; import { _only, _output, _timeout } from "../internal/internal-expressions"; @@ -14,7 +15,6 @@ import type { } from "../types"; import { type BoundQuery, raw, surql } from "../utils"; import type { Frame } from "../utils/frame"; -import type { Duration, RecordIdRange, Table, Uuid } from "../value"; import { Query } from "./query"; interface UpdateOptions { diff --git a/packages/sdk/src/query/upsert.ts b/packages/sdk/src/query/upsert.ts index 6ff98421..fdae9b40 100644 --- a/packages/sdk/src/query/upsert.ts +++ b/packages/sdk/src/query/upsert.ts @@ -1,3 +1,4 @@ +import type { Duration, RecordIdRange, Table, Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { DispatchedPromise } from "../internal/dispatched-promise"; import { _only, _output, _timeout } from "../internal/internal-expressions"; @@ -14,7 +15,6 @@ import type { } from "../types"; import { type BoundQuery, raw, surql } from "../utils"; import type { Frame } from "../utils/frame"; -import type { Duration, RecordIdRange, Table, Uuid } from "../value"; import { Query } from "./query"; interface UpsertOptions { diff --git a/packages/sdk/src/types/diagnostics.ts b/packages/sdk/src/types/diagnostics.ts index 8eb2079e..25656648 100644 --- a/packages/sdk/src/types/diagnostics.ts +++ b/packages/sdk/src/types/diagnostics.ts @@ -1,4 +1,4 @@ -import type { Duration, Uuid } from "../value"; +import type { Duration, Uuid } from "@surrealdb/sqon"; import type { Nullable } from "./helpers"; import type { LiveMessage } from "./live"; import type { NamespaceDatabase, QueryChunk, Session, VersionInfo } from "./surreal"; diff --git a/packages/sdk/src/types/helpers.ts b/packages/sdk/src/types/helpers.ts index a02a31be..19c9ca6d 100644 --- a/packages/sdk/src/types/helpers.ts +++ b/packages/sdk/src/types/helpers.ts @@ -1,4 +1,4 @@ -import type { RecordId, RecordIdValue, StringRecordId } from "../value"; +import type { RecordId, RecordIdValue, StringRecordId } from "@surrealdb/sqon"; export type Version = `${number}.${number}.${number}`; export type Values = Partial & Record; diff --git a/packages/sdk/src/types/internal.ts b/packages/sdk/src/types/internal.ts index b7606d39..25930266 100644 --- a/packages/sdk/src/types/internal.ts +++ b/packages/sdk/src/types/internal.ts @@ -1,29 +1,3 @@ export type Prettify = { [K in keyof T]: T[K] } & {}; export type Field = keyof I | (string & {}); export type Selection = "value" | "fields" | "diff"; - -/** - * Used to widen the type of a record id primitive value. - * - * @example - * ```ts - * new TypedRecordId("test", "123"); // TypedRecordId<"test", string> - * new TypedRecordId("test", 123); // TypedRecordId<"test", number> - * ``` - * - * Without widening the type would be: - * ```ts - * new TypedRecordId("test", "123"); // TypedRecordId<"test", "123"> - * new TypedRecordId("test", 123); // TypedRecordId<"test", 123> - * ``` - * - * Thus preventing us from declaring record ids in arrays or using them - * interchangeably. - */ -export type WidenRecordIdValue = T extends string - ? string - : T extends number - ? number - : T extends bigint - ? bigint - : T; diff --git a/packages/sdk/src/types/live.ts b/packages/sdk/src/types/live.ts index ae179e7a..a10b8ae1 100644 --- a/packages/sdk/src/types/live.ts +++ b/packages/sdk/src/types/live.ts @@ -1,4 +1,4 @@ -import type { RecordId, Table, Uuid } from "../value"; +import type { RecordId, Table, Uuid } from "@surrealdb/sqon"; export const LIVE_ACTIONS = ["CREATE", "UPDATE", "DELETE", "KILLED"] as const; diff --git a/packages/sdk/src/types/rpc.ts b/packages/sdk/src/types/rpc.ts index 2314c84e..3e604d5f 100644 --- a/packages/sdk/src/types/rpc.ts +++ b/packages/sdk/src/types/rpc.ts @@ -1,5 +1,5 @@ +import type { Uuid } from "@surrealdb/sqon"; import type { RpcErrorObject } from "../internal/parse-error"; -import type { Uuid } from "../value"; import type { QueryType, Session } from "./surreal"; export type RpcQueryResult = RpcQueryResultOk | RpcQueryResultErr; diff --git a/packages/sdk/src/types/surreal.ts b/packages/sdk/src/types/surreal.ts index ab513a49..d686d832 100644 --- a/packages/sdk/src/types/surreal.ts +++ b/packages/sdk/src/types/surreal.ts @@ -1,8 +1,15 @@ +import type { + CodecOptions, + Duration, + RecordId, + RecordIdValue, + Uuid, + ValueCodec, +} from "@surrealdb/sqon"; import type { ServerError } from "../errors"; import type { Feature } from "../internal/feature"; import type { ReconnectContext } from "../internal/reconnect"; import type { BoundQuery } from "../utils"; -import type { Duration, RecordId, RecordIdValue, Uuid } from "../value"; import type { AccessRecordAuth, AnyAuth, AuthProvider, Token, Tokens } from "./auth"; import type { Nullable } from "./helpers"; import type { Prettify } from "./internal"; @@ -10,17 +17,23 @@ import type { LiveMessage } from "./live"; import type { EventPublisher } from "./publisher"; export type Session = Uuid | undefined; -export type CodecType = "cbor" | "flatbuffer" | (string & {}); export type QueryResponseKind = "single" | "batched" | "batched-final"; export type ConnectionStatus = "disconnected" | "connecting" | "reconnecting" | "connected"; export type EngineFactory = (context: DriverContext) => SurrealEngine; +export type Codecs = { [K in keyof CodecRegistry]?: (options: CodecOptions) => CodecRegistry[K] }; export type Engines = Record; -export type CodecFactory = (options: CodecOptions) => ValueCodec; -export type Codecs = Partial>; -export type CodecRegistry = Record; export type DataStream = string | ReadableStream; export type QueryType = "live" | "kill" | "other"; +/** + * The registry of codecs supported by the SDK. + */ +export interface CodecRegistry { + cbor: ValueCodec; + flatbuffer: ValueCodec; + json: ValueCodec; +} + /** * The communication contract between the SDK and a SurrealDB datastore. * @@ -190,25 +203,7 @@ export interface ConnectionState { sessions: Map; } -/** - * Options used to configure the value codec - */ -export interface CodecOptions { - /** Use native `Date` objects instead of custom `DateTime` objects. Using `Date` objects will result in a loss of nanosecond precision. */ - useNativeDates?: boolean; - /** Specify a custom visitor function to process encode values. */ - valueEncodeVisitor?: (value: unknown) => unknown; - /** Specify a custom visitor function to process decode values. */ - valueDecodeVisitor?: (value: unknown) => unknown; -} - -/** - * A codec for encoding and decoding SurrealQL values - */ -export interface ValueCodec { - encode: (data: T) => Uint8Array; - decode: (data: Uint8Array) => T; -} +export type { CodecOptions, ValueCodec }; /** * Context information passed to each controller and engine diff --git a/packages/sdk/src/utils/index.ts b/packages/sdk/src/utils/index.ts index 01a1055e..80c0db8e 100644 --- a/packages/sdk/src/utils/index.ts +++ b/packages/sdk/src/utils/index.ts @@ -1,15 +1,10 @@ export * from "./bound-query"; export * from "./channel-iterator"; -export * from "./equals"; -export * from "./escape"; export * from "./expr"; export * from "./features"; export * from "./frame"; export * from "./is-version-supported"; -export * from "./jsonify"; export * from "./live"; export * from "./publisher"; -export * from "./range"; export * from "./string-prefixes"; export * from "./tagged-template"; -export * from "./to-surql-string"; diff --git a/packages/sdk/src/utils/live.ts b/packages/sdk/src/utils/live.ts index 7e423101..999ea737 100644 --- a/packages/sdk/src/utils/live.ts +++ b/packages/sdk/src/utils/live.ts @@ -1,8 +1,8 @@ +import type { Uuid } from "@surrealdb/sqon"; import type { ConnectionController } from "../controller"; import { ConnectionUnavailableError, LiveSubscriptionError } from "../errors"; import { Query } from "../query"; import type { LiveMessage, LiveResource, Session } from "../types"; -import type { Uuid } from "../value"; import { BoundQuery } from "./bound-query"; import { ChannelIterator } from "./channel-iterator"; diff --git a/packages/sdk/src/utils/string-prefixes.ts b/packages/sdk/src/utils/string-prefixes.ts index a3f68e07..c863268a 100644 --- a/packages/sdk/src/utils/string-prefixes.ts +++ b/packages/sdk/src/utils/string-prefixes.ts @@ -1,4 +1,4 @@ -import { DateTime, StringRecordId, Uuid } from "../value"; +import { DateTime, StringRecordId, Uuid } from "@surrealdb/sqon"; /** * A template literal tag function for parsing a string type. diff --git a/packages/sdk/src/value/range.ts b/packages/sdk/src/value/range.ts deleted file mode 100644 index d817f29d..00000000 --- a/packages/sdk/src/value/range.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getRangeJoin } from "../internal/range"; -import { equals } from "../utils/equals"; -import { escapeRangeBound } from "../utils/escape"; -import type { Bound } from "../utils/range"; -import { hasSymbol, markSymbol, RANGE_SYMBOL } from "../utils/symbols"; -import { Value } from "./value"; - -/** - * A SurrealQL range value. - */ -export class Range extends Value { - static override [Symbol.hasInstance](instance: unknown): boolean { - return hasSymbol(instance, RANGE_SYMBOL); - } - - readonly begin: Bound; - readonly end: Bound; - - constructor(beg: Bound, end: Bound) { - super(); - this.begin = beg; - this.end = end; - markSymbol(this, RANGE_SYMBOL); - } - - equals(other: unknown): boolean { - if (!(other instanceof Range)) return false; - const o = other as unknown as Range; - if (this.begin?.constructor !== o.begin?.constructor) return false; - if (this.end?.constructor !== o.end?.constructor) return false; - - return equals(this.begin?.value, o.begin?.value) && equals(this.end?.value, o.end?.value); - } - - toJSON(): string { - return this.toString(); - } - - /** - * @returns The escaped range string - */ - toString(): string { - const beg = escapeRangeBound(this.begin); - const end = escapeRangeBound(this.end); - return `${beg}${getRangeJoin(this.begin, this.end)}${end}`; - } -} diff --git a/packages/sqon/LICENSE b/packages/sqon/LICENSE new file mode 100644 index 00000000..1fb18fb3 --- /dev/null +++ b/packages/sqon/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright © SurrealDB Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sqon/build.ts b/packages/sqon/build.ts new file mode 100644 index 00000000..6d32bfdd --- /dev/null +++ b/packages/sqon/build.ts @@ -0,0 +1,29 @@ +import { rolldown } from "rolldown"; + +const bundle = await rolldown({ + input: "./src/index.ts", +}); +await bundle.write({ format: "esm", file: "./dist/sqon.mjs" }); +await bundle.write({ format: "cjs", file: "./dist/sqon.cjs" }); + +const task = Bun.spawn( + [ + "bunx", + "dts-bundle-generator", + "-o", + "./dist/sqon.d.ts", + "./src/index.ts", + "--no-check", + "--export-referenced-types", + "false", + ], + { + stdout: "inherit", + stderr: "inherit", + async onExit(_, exitCode) { + if (exitCode !== 0) process.exit(exitCode); + }, + }, +); + +await task.exited; diff --git a/packages/sqon/package.json b/packages/sqon/package.json new file mode 100644 index 00000000..926c7004 --- /dev/null +++ b/packages/sqon/package.json @@ -0,0 +1,40 @@ +{ + "name": "@surrealdb/sqon", + "version": "0.1.0", + "type": "module", + "license": "Apache-2.0", + "description": "SQON value types and codecs for SurrealDB.", + "homepage": "https://github.com/surrealdb/surrealdb.js", + "repository": { + "type": "git", + "url": "https://github.com/surrealdb/surrealdb.js.git" + }, + "scripts": { + "build": "bun run build.ts", + "deploy": "bun run ../../scripts/publish.ts" + }, + "devDependencies": { + "@types/bun": "^1.2.21" + }, + "peerDependencies": { + "typescript": "^5.0.0", + "tslib": "^2.6.3" + }, + "dependencies": { + "@surrealdb/cbor": "2.0.0-alpha.4", + "uuidv7": "^1.0.1" + }, + "types": "./dist/sqon.d.ts", + "main": "./dist/sqon.mjs", + "exports": { + ".": { + "types": "./dist/sqon.d.ts", + "require": "./dist/sqon.cjs", + "import": "./dist/sqon.mjs" + } + }, + "files": [ + "dist", + "LICENSE" + ] +} diff --git a/packages/sqon/res/SQON_SPECIFICATION.md b/packages/sqon/res/SQON_SPECIFICATION.md new file mode 100644 index 00000000..1fa01705 --- /dev/null +++ b/packages/sqon/res/SQON_SPECIFICATION.md @@ -0,0 +1,469 @@ +# SQON - SurrealQL Object Notation + +Internal lead: Julian Mills +Last edited time: April 7, 2026 8:48 PM +Status: Draft + +# SurrealQL Object Notation Specification + +SQON (SurrealQL Object Notation) is the family of data representation formats used by SurrealDB to encode its rich data value system. Three representations exist, each optimised for a different environment: + +| Name | Encoding | Use case | +| --- | --- | --- | +| SQON | SurrealQL (text) | Direct database interaction, queries, SurrealQL expressions | +| SQON Binary | CBOR (binary) | Efficient wire transport, compact storage, binary-safe environments | +| SQON JSON | JSON (text) | HTTP APIs, browser environments, human-readable interchange | + +All three representations are semantically equivalent. Any value expressible in one format can be round-tripped through any other without loss of type information. + +## 1. Value types + +SQON represents the full SurrealQL value system. Every value in SurrealDB belongs to exactly one of the types listed below. This section provides a comprehensive reference; the format-specific sections that follow describe how each type is encoded in SQON, SQON Binary, and SQON JSON. + +### 1.1 Type descriptions + +### None + +`none` represents the explicit absence of a value. It is distinct from `null` and is used to indicate that a field has no value at all. Responses typically omit `none`-valued fields entirely rather than including them. + +### Null + +`null` represents an unknown or undefined value. While semantically similar to `none`, `null` conveys "value is unknown" rather than "value is absent". + +### Bool + +A boolean value: `true` or `false`. + +### Number + +SurrealDB supports three numeric subtypes, all of which fall under the umbrella `number` type: + +| Subtype | Storage | Range / precision | SQON syntax | +| --- | --- | --- | --- | +| `int` | 64-bit signed integer | −9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 | `42` | +| `float` | 64-bit IEEE 754 double | ≈15–17 significant decimal digits | `3.14` or `3.14f` | +| `decimal` | 128-bit decimal floating point | Arbitrary precision, no IEEE 754 rounding | `3.14dec` | + +A numeric literal without a decimal point and within the `int` range is stored as an `int`. A literal with a decimal point or outside the `int` range is stored as a `float`. + +Underscores in numeric literals are ignored and can be used for readability (e.g. `1_000_000`). + +### String + +A UTF-8 encoded text value of arbitrary length. Strings can contain Unicode characters, emojis, tabs, and line breaks. + +### Duration + +A non-negative time span with nanosecond precision. Durations are composed of one or more unit segments: + +| Unit | Meaning | +| --- | --- | +| `ns` | Nanoseconds | +| `us` / `µs` | Microseconds | +| `ms` | Milliseconds | +| `s` | Seconds | +| `m` | Minutes | +| `h` | Hours | +| `d` | Days | +| `w` | Weeks | +| `y` | Years | + +Units can be combined in a single literal: `1y2w3d4h5m6s7ms8us9ns`. A duration can be zero (`0ns`) but cannot be negative. + +### Datetime + +An RFC 3339 / ISO 8601 timestamp with nanosecond precision. Datetimes are stored internally as UTC; a timezone offset in the input is converted to UTC on storage. + +### UUID + +A universally unique identifier conforming to RFC 4122. SurrealDB supports UUID v4 (random) and v7 (time-ordered). + +### Array + +An ordered, indexed collection of values. Arrays may contain values of any type, including nested arrays and objects. Individual elements are accessed by zero-based index. An optional element type and length constraint can be specified in schema definitions (e.g. `array`). + +### Set + +An ordered, automatically deduplicated collection of values. Sets differ from arrays in two ways: duplicate values are removed, and values are sorted. Sets support the same element type and length constraints as arrays in schema definitions. + +### Object + +An unordered key-value map with string keys and values of any type. Objects may be nested and can contain any other value type. + +### Geometry + +A geospatial value conforming to RFC 7946 (GeoJSON). SurrealDB supports the following geometry subtypes: + +| Subtype | Description | +| --- | --- | +| `Point` | A single position (longitude, latitude) | +| `LineString` | An ordered sequence of positions | +| `Polygon` | A closed shape with an exterior ring and optional interior rings (holes) | +| `MultiPoint` | A collection of points | +| `MultiLineString` | A collection of line strings | +| `MultiPolygon` | A collection of polygons | +| `GeometryCollection` | A heterogeneous collection of geometry objects | + +### Bytes + +Raw binary data. Bytes are typically displayed in hexadecimal encoding. + +### Table + +A bare reference to a table name (opposed to a specific record). Table values are often used to distinguish between a table name and a record ID in contexts where both are possible. + +### Record ID + +A record ID uniquely identifies a single record within a table. It is composed of two parts: a **table name** and an **identifier**. + +The identifier can take several forms: + +| Form | Example (SQON) | +| --- | --- | +| Text | `user:tobie` | +| Numeric (64-bit int) | `user:42` | +| UUID | `user:⟨01924b3c-f1a2-7e3d-a001-2f4b8c9d0e1f⟩` | +| Array (composite key) | `temperature:['London', d'2025-02-13']` | +| Object (structured key) | `user:{ name: 'john', age: 30 }` | +| Generated | `user:rand()`, `user:ulid()`, `user:uuid()` | + +Record IDs are immutable and double as record links — holding a record ID is sufficient to traverse to another record's data. + +### File + +A reference to a file in a storage bucket. + +### Range + +A bounded or unbounded range of values. Ranges are composed of the `..` operator with optional lower and upper bounds: + +| Syntax | Meaning | +| --- | --- | +| `a..b` | From `a` (inclusive) to `b` (exclusive) | +| `a..=b` | From `a` (inclusive) to `b` (inclusive) | +| `a>..b` | From `a` (exclusive) to `b` (exclusive) | +| `a>..=b` | From `a` (exclusive) to `b` (inclusive) | +| `a..` | From `a` (inclusive), unbounded above | +| `..b` | Unbounded below, to `b` (exclusive) | +| `..` | Fully unbounded (infinite range) | + +Ranges can be constructed from any value type supporting comparison. + +## 2. SQON + +**MIME:** `application/vnd.surrealdb.sqon` + +SQON is the native textual syntax of SurrealDB. While inspired by JSON, it more closely resembles ECMAScript objects, including the ability for object keys to omit quotes, and the ability to use both single and double quotes for strings. It is optimised for direct use within the database — in queries, schema definitions, and results returned over the SurrealDB binary protocol. + +Since SQON is a subset of SurrealQL, it is not designed for portability across application boundaries. Parsing SQON requires a custom parser implementation, making it unsuitable for use in general-purpose HTTP environments or third-party clients that do not embed the SurrealDB parser. + +### 2.1 Characteristics + +- **Concise** — SQON as well as SurrealQL are designed to be flexible and easy to write and read +- **Specialised** — the syntax is custom to SurrealDB and designed for optimal user experience +- **Readable** — custom syntax for each type makes types explicit and clearly distinct from each other + +### 2.2 Summary of SQON value syntax + +The following table summarises SQON syntax for all value types. Primitive JSON-compatible types use familiar syntax; SurrealDB-specific types use bespoke syntax. + +### Primitive types (JSON-compatible) + +| Type | SQON example | +| --- | --- | +| None | `NONE` | +| Null | `NULL` | +| Bool | `true` / `false` | +| Int | `42` | +| Float | `3.14` / `3.14f` | +| String | `'hello'` / `"hello"` | +| Array | `[1, 2, 3]` | +| Object | `{ name: 'Jane', age: 30 }` | + +### SurrealDB-specific types + +| Type | SQON example | +| --- | --- | +| Decimal | `3.14159265358979dec` | +| Duration | `1h30m` / `2w3d` / `100ms` | +| Datetime | `d"2024-01-15T09:30:00Z"` | +| UUID | `u"01924b3c-f1a2-7e3d-a001-2f4b8c9d0e1f"` | +| Set | `{1, 2, 3}` | +| Bytes | `b"48656C6C6F"` | +| Table | `person` (bare identifier) | +| Record ID | `user:abc123` | +| Record ID (numeric) | `user:42` | +| Record ID (UUID) | `user:⟨01924b3c-f1a2-7e3d-a001-2f4b8c9d0e1f⟩` | +| Record ID (object) | `user:{ name: 'john', age: 30 }` | +| Record ID (array) | `temperature:['London', d'2025-02-13']` | +| File | `f"bucket:/path/to/file.txt"` | +| Range | `0..10` / `0..=10` / `0>..10` | +| Geometry (point) | `(-122.4194, 37.7749)` | + +## 3. SQON Binary + +**MIME:** `application/vnd.surrealdb.sqon+cbor` + +SQON Binary is the binary serialisation format for SQON values. The current implementation is based on **CBOR** (Concise Binary Object Representation, RFC 8949) with the addition of custom tags to represent SurrealDB-specific types. It is the current wire format used by the SurrealDB WebSocket and HTTP binary endpoints. Note that in the future we intend on replacing CBOR communication with flat buffers. + +Each SurrealDB-specific type is assigned a custom CBOR tag that unambiguously identifies its type and governs how its payload is decoded. + +### 3.1 Characteristics + +- **Compact** — binary encoding is significantly smaller than equivalent JSON for numeric and binary payloads. +- **Efficient** — suitable for high-throughput, low-latency environments. +- **Not human-readable** — requires a CBOR-aware decoder; not suitable for direct use in HTTP response bodies consumed by browsers or curl. + +### 3.2 Native CBOR types (no custom tag) + +The following types map directly to native CBOR major types and require no custom tag: + +| SQON type | CBOR encoding | +| --- | --- | +| `null` | CBOR `null` (major type 7, value 22) | +| `bool` | CBOR `true` / `false` (major type 7) | +| `int` | CBOR integer (major type 0 or 1) | +| `float` | CBOR float (major type 7, half/single/double) | +| `string` | CBOR text string (major type 3) | +| `bytes` | CBOR byte string (major type 2) | +| `array` | CBOR array (major type 4) | +| `object` | CBOR map (major type 5) | + +### 3.3 Custom tag assignments + +| SQON type | CBOR tag | Payload | +| --- | --- | --- | +| `None` | 6 | `null` | +| `Table` | 7 | Text string (table name) | +| `RecordId` | 8 | `[table: text, id: any]` | +| `UUID` | 9 or 37 | 16-byte bytestring (tag 37 is the IANA-registered UUID tag; tag 9 decodes a string-encoded UUID) | +| `Decimal` | 10 | Text string (decimal notation) | +| `Datetime` | 12 | `[seconds: uint, nanoseconds: uint]` (tag 0 is also accepted for RFC 3339 text string input) | +| `Duration` | 13 or 14 | Tag 13: text string; Tag 14: `[seconds: uint, nanoseconds: uint]` | +| `Future` | 15 | Text string (SurrealQL expression body) | +| `Range` | 49 | `[begin: Bound, end: Bound]` | +| `BoundIncluded` | 50 | The included bound value | +| `BoundExcluded` | 51 | The excluded bound value | +| `File` | 55 | `[bucket: text, key: text]` | +| `Set` | 56 | CBOR array of values | +| `Geometry (Point)` | 88 | `[longitude: float, latitude: float]` | +| `Geometry (LineString)` | 89 | Array of coordinate pairs | +| `Geometry (Polygon)` | 90 | Array of linear rings | +| `Geometry (MultiPoint)` | 91 | Array of points | +| `Geometry (MultiLineString)` | 92 | Array of line strings | +| `Geometry (MultiPolygon)` | 93 | Array of polygons | +| `Geometry (Collection)` | 94 | Array of geometry objects | + +## 4. SQON JSON + +**MIME:** `application/vnd.surrealdb.sqon+json` + +SQON JSON is the JSON-compatible serialisation format for SQON values. It enables full type fidelity in environments where only JSON is available — HTTP APIs, browser clients, logging infrastructure, debugging tools, and any context where binary encoding is impractical. While SQON JSON is the least compact representation of SurrealDB types, it is the most portable and widely accepted format. + +The SQON JSON format builds on top of JSON, with the addition of `$` prefixed notations describing custom SurrealDB types. This mirrors the EJSON specification from MongoDB. + +### 4.1 Characteristics + +- **Portable** - usable in any environment where data is represented by JSON +- **LLM Friendly** - allowing for optimal communication with LLMs through JSON +- **Readable** - unlike SQON-B this is directly readable, albeit not as concise as base SQON + +### 4.2 Type encodings + +### 4.2.1 Primitive passthrough + +JSON-native types are passed through without wrapping. + +| SQON type | SQON JSON encoding | +| --- | --- | +| `null` | `null` | +| `bool` | `true` / `false` | +| `int` | `42` | +| `float` | `3.14` | +| `string` | `"hello"` | +| `array` | `[...]` | +| `object` | `{...}` | + +### 4.2.2 None + +A `$none` key with a value of `true` is used to represent a `none` value. + +```json +{ "$none": true } +``` + +### 4.2.3 UUID + +A `$uuid` key with a value of a UUID string is used to represent a `uuid` value. + +```json +{ "$uuid": "01924b3c-f1a2-7e3d-a001-2f4b8c9d0e1f" } +``` + +### 4.2.4 Datetime + +A `$datetime` key with a value of a RFC 3339 / ISO 8601 string is used to represent a `datetime` value. + +```json +{ "$datetime": "2024-01-15T09:30:00Z" } +``` + +### 4.2.5 Duration + +A `$duration` key with a value of a SurrealDB duration string is used to represent a `duration` value. + +```json +{ "$duration": "1h30m" } +``` + +### 4.2.6 Decimal + +A `$decimal` key with a value of an arbitrary-precision decimal string is used to represent a `decimal` value. + +```json +{ "$decimal": "3.14159265358979323846" } +``` + +### 4.2.7 Bytes + +A `$bytes` key with a value of a Base64url-encoded (no padding) bytestring is used to represent a `bytes` value. + +```json +{ "$bytes": "aGVsbG8" } +``` + +### 4.2.8 RecordId + +A `$recordId` key with an object containing a `tb` property with a value of a table name and a `id` property with a value of a record ID is used to represent a `recordId` value. + +**Text ID:** + +```json +{ "$recordId": { "tb": "user", "id": "abc123" } } +``` + +**Numeric ID:** + +```json +{ "$recordId": { "tb": "order", "id": 42 } } +``` + +**UUID ID:** + +```json +{ "$recordId": { "tb": "user", "id": { "$uuid": "01924b3c-f1a2-7e3d-a001-2f4b8c9d0e1f" } } } +``` + +**Object ID:** + +```json +{ "$recordId": { "tb": "user", "id": { "name": "john", "age": 30 } } } +``` + +**Array ID:** + +```json +{ "$recordId": { "tb": "temperature", "id": [51.5074, -0.1278] } } +``` + +### 4.2.9 Table + +A bare table reference (not a record ID). + +```json +{ "$table": "user" } +``` + +### 4.2.10 Geometry + +A `$geometry` key with an object conforming to the GeoJSON specification is used to represent a `geometry` value. + +**Point:** + +```json +{ "$geometry": { "type": "Point", "coordinates": [-122.4194, 37.7749] } } +``` + +**Polygon:** + +```json +{ "$geometry": { "type": "Polygon", "coordinates": [[[0,0],[1,0],[1,1],[0,1],[0,0]]] } } +``` + +### 4.2.11 Set + +A `$set` key with a value of a JSON array is used to represent a `set` value. + +```json +{ "$set": [1, 2, 3] } +``` + +### 4.2.12 File + +A `$file` key with an object containing a `bucket` property with a value of a bucket name and a `key` property with a value of a file key (path) is used to represent a `file` value. + +```json +{ "$file": { "bucket": "images", "key": "/photos/avatar.png" } } +``` + +### 4.2.13 Range + +A `$range` key with an object containing a `begin` property with a value of a bound and an `end` property with a value of a bound is used to represent a `range` value. + +```json +{ "$range": { "begin": { "$boundIncluded": 0 }, "end": { "$boundExcluded": 10 } } } +``` + +**Inclusive upper bound:** + +```json +{ "$range": { "begin": { "$boundIncluded": 0 }, "end": { "$boundIncluded": 10 } } } +``` + +**Unbounded (open-ended):** + +```json +{ "$range": { "begin": { "$boundIncluded": 0 }, "end": null } } +``` + +### 4.2.14 Explicit objects + +An `$object` key containing an object which is explicitly escaped from having its keys parsed. Used whenever an input object contains `$`-prefixed keys. + +```json +{ "$object": { "$foo": "bar" } } +``` + +### 4.3 Full document example + +A SurrealDB record serialised as SQON-J: + +```json +{ + "id": { "$recordId": { "tb": "user", "id": { "$uuid": "01924b3c-f1a2-7e3d-a001-2f4b8c9d0e1f" } } }, + "name": "Jane Smith", + "created_at": { "$datetime": "2024-01-15T09:30:00Z" }, + "session_duration": { "$duration": "1h45m" }, + "balance": { "$decimal": "1042.50" }, + "location": { "$geometry": { "type": "Point", "coordinates": [-0.1278, 51.5074] } }, + "tags": { "$set": ["admin", "verified"] }, + "avatar": { "$file": { "bucket": "uploads", "key": "/avatars/jane.png" } }, + "metadata": { + "score": 9.8, + "flags": 3 + } +} +``` + +## 5. Choosing a representation + +| Situation | Recommended format | +| --- | --- | +| Writing a SurrealQL query | SQON | +| SurrealDB native WebSocket / binary protocol | SQON-B | +| General purpose REST API response body | SQON-J | +| Browser client receiving query results | SQON-J | +| Storing a record in a log or audit trail | SQON-J | +| Embedding SurrealDB values in a JSON config file | SQON-J | +| High-throughput internal service-to-service transport | SQON-B | +| Debugging / human inspection of a record | SQON-J or SQON | \ No newline at end of file diff --git a/packages/sdk/src/cbor/codec.ts b/packages/sqon/src/codec/cbor/codec.ts similarity index 91% rename from packages/sdk/src/cbor/codec.ts rename to packages/sqon/src/codec/cbor/codec.ts index 2bed03bf..301055ab 100644 --- a/packages/sdk/src/cbor/codec.ts +++ b/packages/sqon/src/codec/cbor/codec.ts @@ -1,6 +1,6 @@ import { decode, encode, type Replacer, Tagged } from "@surrealdb/cbor"; -import type { CodecOptions, ValueCodec } from "../types"; -import { BoundExcluded, BoundIncluded } from "../utils/range"; +import type { CodecOptions, ValueCodec } from "../../types/codec.ts"; +import { BoundExcluded, BoundIncluded } from "../../utils/range.ts"; import { DATETIME_SYMBOL, DECIMAL_SYMBOL, @@ -21,7 +21,9 @@ import { STRING_RECORD_ID_SYMBOL, TABLE_SYMBOL, UUID_SYMBOL, -} from "../utils/symbols"; +} from "../../utils/symbols.ts"; +import type { DateTimeTuple } from "../../value/datetime.ts"; +import { FileRef } from "../../value/file.ts"; import { DateTime, Decimal, @@ -37,13 +39,11 @@ import { Range, RecordId, RecordIdRange, - type StringRecordId, + StringRecordId, Table, Uuid, -} from "../value"; -import type { DateTimeTuple } from "../value/datetime"; -import { FileRef } from "../value/file"; -import { cborToRange, rangeToCbor } from "./utils"; +} from "../../value/index.ts"; +import { cborToRange, rangeToCbor } from "./utils.ts"; // Tags from the spec - https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml const TAG_SPEC_DATETIME = 0; @@ -82,9 +82,14 @@ const TAG_GEOMETRY_MULTIPOLYGON = 93; const TAG_GEOMETRY_COLLECTION = 94; /** - * A class used to encode and decode SurrealQL values using CBOR + * A codec for encoding and decoding SurrealQL values using the SQON Binary (cbor) format. */ -export class CborCodec implements ValueCodec { +export class CborCodec implements ValueCodec { + /** + * The default CborCodec instance. + */ + static readonly DEFAULT: CborCodec = new CborCodec({}); + #options: CodecOptions; constructor(options: CodecOptions) { @@ -201,6 +206,9 @@ export class CborCodec implements ValueCodec { [TAG_BOUND_INCLUDED]: (v) => this.#decodeValue(new BoundIncluded(v)), [TAG_BOUND_EXCLUDED]: (v) => this.#decodeValue(new BoundExcluded(v)), [TAG_RECORDID]: (v) => { + if (typeof v === "string") { + return this.#decodeValue(new StringRecordId(v)); + } if (hasSymbol(v[1], RANGE_SYMBOL)) { return this.#decodeValue(new RecordIdRange(v[0], v[1].begin, v[1].end)); } diff --git a/packages/sdk/src/cbor/index.ts b/packages/sqon/src/codec/cbor/index.ts similarity index 100% rename from packages/sdk/src/cbor/index.ts rename to packages/sqon/src/codec/cbor/index.ts diff --git a/packages/sdk/src/cbor/utils.ts b/packages/sqon/src/codec/cbor/utils.ts similarity index 79% rename from packages/sdk/src/cbor/utils.ts rename to packages/sqon/src/codec/cbor/utils.ts index 95fd686a..9aa5aa19 100644 --- a/packages/sdk/src/cbor/utils.ts +++ b/packages/sqon/src/codec/cbor/utils.ts @@ -1,8 +1,8 @@ import { Tagged } from "@surrealdb/cbor"; -import { SurrealError } from "../errors"; -import type { Bound, BoundExcluded, BoundIncluded } from "../utils/range"; -import { BOUND_EXCLUDED_SYMBOL, BOUND_INCLUDED_SYMBOL, hasSymbol } from "../utils/symbols"; -import { TAG_BOUND_EXCLUDED, TAG_BOUND_INCLUDED } from "./codec"; +import { SqonError } from "../../errors.ts"; +import type { Bound, BoundExcluded, BoundIncluded } from "../../utils/range.ts"; +import { BOUND_EXCLUDED_SYMBOL, BOUND_INCLUDED_SYMBOL, hasSymbol } from "../../utils/symbols.ts"; +import { TAG_BOUND_EXCLUDED, TAG_BOUND_INCLUDED } from "./codec.ts"; type DecodedBound = BoundIncluded | BoundExcluded | null; @@ -29,7 +29,7 @@ export function cborToRange( if (bound === null) return undefined; if (hasSymbol(bound, BOUND_INCLUDED_SYMBOL)) return bound; if (hasSymbol(bound, BOUND_EXCLUDED_SYMBOL)) return bound; - throw new SurrealError("Expected the bounds to be decoded already"); + throw new SqonError("Expected the bounds to be decoded already"); } return [decodeBound(range[0]), decodeBound(range[1])]; diff --git a/packages/sqon/src/codec/flatbuffer/codec.ts b/packages/sqon/src/codec/flatbuffer/codec.ts new file mode 100644 index 00000000..069f0cb8 --- /dev/null +++ b/packages/sqon/src/codec/flatbuffer/codec.ts @@ -0,0 +1,29 @@ +import { SqonError } from "../../errors.ts"; +import type { CodecOptions, ValueCodec } from "../../types/codec.ts"; + +/** + * A codec for encoding and decoding SurrealQL values using the SQON Binary (flatbuffer) format. + * + * *Note*: This codec is not implemented in this version. + */ +export class FlatBufferCodec implements ValueCodec { + /** + * The default FlatBufferCodec instance. + */ + static readonly DEFAULT: FlatBufferCodec = new FlatBufferCodec({}); + + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: Todo + #options: CodecOptions; + + constructor(options: CodecOptions) { + this.#options = options; + } + + encode(_data: T): Uint8Array { + throw new SqonError("FlatBuffer encoding is not supported in this version"); + } + + decode(_data: Uint8Array): T { + throw new SqonError("FlatBuffer decoding is not supported in this version"); + } +} diff --git a/packages/sqon/src/codec/flatbuffer/index.ts b/packages/sqon/src/codec/flatbuffer/index.ts new file mode 100644 index 00000000..58c3aa1a --- /dev/null +++ b/packages/sqon/src/codec/flatbuffer/index.ts @@ -0,0 +1 @@ +export { FlatBufferCodec } from "./codec.ts"; diff --git a/packages/sqon/src/codec/json/codec.ts b/packages/sqon/src/codec/json/codec.ts new file mode 100644 index 00000000..f9d292bb --- /dev/null +++ b/packages/sqon/src/codec/json/codec.ts @@ -0,0 +1,301 @@ +import { fromBase64Url, toBase64Url } from "../../internal/base64.ts"; +import type { CodecOptions, ValueCodec } from "../../types/codec.ts"; +import { BoundExcluded, BoundIncluded } from "../../utils/range.ts"; +import { + DateTime, + Decimal, + Duration, + FileRef, + Future, + Geometry, + Range, + RecordId, + RecordIdRange, + type RecordIdValue, + StringRecordId, + Table, + Uuid, +} from "../../value/index.ts"; +import { + createBoundExcluded, + createBoundIncluded, + createBytes, + createDatetime, + createDecimal, + createDuration, + createFile, + createFuture, + createGeometry, + createNone, + createObject, + createRange, + createRecordId, + createSet, + createStringRecordId, + createTable, + createUuid, + isBoundExcluded, + isBoundIncluded, + isBytes, + isDatetime, + isDecimal, + isDuration, + isFile, + isFuture, + isGeometry, + isNone, + isObject, + isRange, + isRecordId, + isSet, + isStringRecordId, + isTable, + isUuid, +} from "./values.ts"; + +/** + * A codec for encoding and decoding SurrealQL values using the SQON Json format. + */ +export class JsonCodec implements ValueCodec { + /** + * The default JsonCodec instance. + */ + static readonly DEFAULT: JsonCodec = new JsonCodec({}); + + #options: CodecOptions; + + constructor(options: CodecOptions) { + this.#options = options; + } + + /** + * Encode a value tree to a SQON-J structure (JSON-safe plain object tree). + */ + encode(data: T): unknown { + return this.#serialize(data); + } + + /** + * Decode a SQON-J structure back to value instances. + */ + decode(data: unknown): T { + return this.#deserialize(data) as T; + } + + #serialize(input: unknown): unknown { + const value = this.#encodeValue(input); + + if (this.#isEncodePrimitive(value)) { + return value; + } + if (value === undefined) { + return createNone(); + } + if (value instanceof Date) { + return createDatetime(value.toISOString()); + } + if (value instanceof DateTime) { + return createDatetime(value.toISOString()); + } + if (value instanceof Decimal) { + return createDecimal(value.toString()); + } + if (value instanceof Duration) { + return createDuration(value.toString()); + } + if (value instanceof Uuid) { + return createUuid(value.toString()); + } + if (value instanceof RecordId) { + return createRecordId(value.table.name, this.#serialize(value.id)); + } + if (value instanceof StringRecordId) { + return createStringRecordId(value.toString()); + } + if (value instanceof RecordIdRange) { + return createRecordId( + value.table.name, + createRange(this.#encodeBound(value.begin), this.#encodeBound(value.end)), + ); + } + if (value instanceof Table) { + return createTable(value.name); + } + if (value instanceof Range) { + return createRange(this.#encodeBound(value.begin), this.#encodeBound(value.end)); + } + if (value instanceof FileRef) { + return createFile(value.bucket, value.key); + } + if (value instanceof Future) { + return createFuture(value.body); + } + if (value instanceof Uint8Array) { + return createBytes(toBase64Url(value)); + } + if (value instanceof Geometry) { + return createGeometry(value.toJSON()); + } + if (value instanceof Set) { + return createSet([...value].map((v) => this.#serialize(v))); + } + + switch (Object.getPrototypeOf(value)) { + case Array.prototype: + return (value as unknown[]).map((v) => this.#serialize(v)); + case Map.prototype: { + return Object.fromEntries( + Array.from((value as Map).entries()) + .map(([k, v]) => [k, this.#serialize(v)]) + .filter(([, encoded]) => encoded !== undefined), + ); + } + case Object.prototype: { + const obj = value as Record; + const result: Record = {}; + let escaped = false; + + for (const key in obj) { + const encoded = this.#serialize(obj[key]); + + if (encoded !== undefined) { + result[key] = encoded; + if (key[0] === "$") escaped = true; + } + } + + return escaped ? createObject(result) : result; + } + } + + return value; + } + + #encodeBound(bound: unknown): unknown { + if (bound instanceof BoundIncluded) { + return createBoundIncluded(this.#serialize(bound.value)); + } + if (bound instanceof BoundExcluded) { + return createBoundExcluded(this.#serialize(bound.value)); + } + + return null; + } + + #isEncodePrimitive(value: unknown): boolean { + return ( + value === null || + typeof value === "boolean" || + typeof value === "string" || + typeof value === "number" || + typeof value === "bigint" + ); + } + + #deserialize(input: unknown): unknown { + if (this.#isDecodePrimitive(input)) { + return input; + } + + if (Array.isArray(input)) { + return input.map((v) => this.#deserialize(v)); + } + + const obj = input as Record; + + if (isObject(obj)) { + return Object.fromEntries( + Object.entries(obj.$object) + .map(([k, v]) => [k, this.#deserialize(v)]) + .filter(([, decoded]) => decoded !== undefined), + ); + } + if (isNone(obj)) { + return this.#decodeValue(undefined); + } + if (isDatetime(obj)) { + return this.#decodeValue(this.#resolveSpecDate(obj.$datetime)); + } + if (isDecimal(obj)) { + return this.#decodeValue(new Decimal(obj.$decimal)); + } + if (isDuration(obj)) { + return this.#decodeValue(new Duration(obj.$duration)); + } + if (isUuid(obj)) { + return this.#decodeValue(new Uuid(obj.$uuid)); + } + if (isRecordId(obj)) { + const id = this.#deserialize(obj.$recordId.id); + if (id instanceof Range) { + return this.#decodeValue(new RecordIdRange(obj.$recordId.tb, id.begin, id.end)); + } + return this.#decodeValue(new RecordId(obj.$recordId.tb, id as RecordIdValue)); + } + if (isStringRecordId(obj)) { + return this.#decodeValue(new StringRecordId(obj.$recordIdString)); + } + if (isTable(obj)) { + return this.#decodeValue(new Table(obj.$table)); + } + if (isGeometry(obj)) { + return this.#decodeValue(Geometry.fromJSON(obj.$geometry)); + } + if (isSet(obj)) { + return this.#decodeValue(new Set(obj.$set.map((v: unknown) => this.#deserialize(v)))); + } + if (isFile(obj)) { + return this.#decodeValue(new FileRef(obj.$file.bucket, obj.$file.key)); + } + if (isRange(obj)) { + return this.#decodeValue( + new Range(this.#decodeBound(obj.$range.begin), this.#decodeBound(obj.$range.end)), + ); + } + if (isBytes(obj)) { + return this.#decodeValue(fromBase64Url(obj.$bytes)); + } + if (isFuture(obj)) { + return this.#decodeValue(new Future(obj.$future)); + } + + return Object.fromEntries( + Object.entries(obj) + .map(([k, v]) => [k, this.#deserialize(v)]) + .filter(([, decoded]) => decoded !== undefined), + ); + } + + #decodeBound(input: unknown): BoundIncluded | BoundExcluded | undefined { + if (this.#isDecodePrimitive(input)) { + return undefined; + } + + const obj = input as Record; + + if (isBoundIncluded(obj)) { + return new BoundIncluded(this.#deserialize(obj.$boundIncluded)); + } + if (isBoundExcluded(obj)) { + return new BoundExcluded(this.#deserialize(obj.$boundExcluded)); + } + + return undefined; + } + + #isDecodePrimitive(value: unknown): boolean { + return value === null || value === undefined || typeof value !== "object"; + } + + #resolveSpecDate(v: string): unknown { + return this.#options.useNativeDates ? new Date(v) : new DateTime(v); + } + + #encodeValue(v: unknown): unknown { + return this.#options.valueEncodeVisitor ? this.#options.valueEncodeVisitor(v) : v; + } + + #decodeValue(v: unknown): unknown { + return this.#options.valueDecodeVisitor ? this.#options.valueDecodeVisitor(v) : v; + } +} diff --git a/packages/sqon/src/codec/json/index.ts b/packages/sqon/src/codec/json/index.ts new file mode 100644 index 00000000..369a2514 --- /dev/null +++ b/packages/sqon/src/codec/json/index.ts @@ -0,0 +1 @@ +export { JsonCodec } from "./codec.ts"; diff --git a/packages/sqon/src/codec/json/values.ts b/packages/sqon/src/codec/json/values.ts new file mode 100644 index 00000000..68ac8229 --- /dev/null +++ b/packages/sqon/src/codec/json/values.ts @@ -0,0 +1,94 @@ +import type { GeoJson } from "../../value/geometry"; + +export const createNone = () => ({ $none: true }); +export const createDatetime = (value: string) => ({ $datetime: value }); +export const createDecimal = (value: string) => ({ $decimal: value }); +export const createDuration = (value: string) => ({ $duration: value }); +export const createUuid = (value: string) => ({ $uuid: value }); +export const createRecordId = (tb: string, id: unknown) => ({ $recordId: { tb, id } }); +export const createStringRecordId = (value: string) => ({ $recordIdString: value }); +export const createTable = (name: string) => ({ $table: name }); +export const createRange = (begin: unknown, end: unknown) => ({ $range: { begin, end } }); +export const createBoundIncluded = (value: unknown) => ({ $boundIncluded: value }); +export const createBoundExcluded = (value: unknown) => ({ $boundExcluded: value }); +export const createFile = (bucket: string, key: string) => ({ $file: { bucket, key } }); +export const createFuture = (body: string) => ({ $future: body }); +export const createBytes = (value: string) => ({ $bytes: value }); +export const createGeometry = (value: unknown) => ({ $geometry: value }); +export const createSet = (items: unknown[]) => ({ $set: items }); +export const createObject = (value: Record) => ({ $object: value }); + +export const isNone = (value: object): value is { $none: true } => { + return "$none" in value && value.$none === true; +}; + +export const isDatetime = (value: object): value is { $datetime: string } => { + return "$datetime" in value && typeof value.$datetime === "string"; +}; + +export const isDecimal = (value: object): value is { $decimal: string } => { + return "$decimal" in value && typeof value.$decimal === "string"; +}; + +export const isDuration = (value: object): value is { $duration: string } => { + return "$duration" in value && typeof value.$duration === "string"; +}; + +export const isUuid = (value: object): value is { $uuid: string } => { + return "$uuid" in value && typeof value.$uuid === "string"; +}; + +export const isStringRecordId = (value: object): value is { $recordIdString: string } => { + return "$recordIdString" in value && typeof value.$recordIdString === "string"; +}; + +export const isRecordId = (value: object): value is { $recordId: { tb: string; id: unknown } } => { + return ( + "$recordId" in value && + !!value.$recordId && + typeof value.$recordId === "object" && + "tb" in value.$recordId && + typeof value.$recordId.tb === "string" && + "id" in value.$recordId + ); +}; + +export const isTable = (value: object): value is { $table: string } => { + return "$table" in value && typeof value.$table === "string"; +}; + +export const isGeometry = (value: object): value is { $geometry: GeoJson } => { + return "$geometry" in value && typeof value.$geometry === "object" && value.$geometry !== null; +}; + +export const isSet = (value: object): value is { $set: unknown[] } => { + return "$set" in value && Array.isArray(value.$set); +}; + +export const isFile = (value: object): value is { $file: { bucket: string; key: string } } => { + return "$file" in value && typeof value.$file === "object" && value.$file !== null; +}; + +export const isRange = (value: object): value is { $range: { begin: unknown; end: unknown } } => { + return "$range" in value && typeof value.$range === "object" && value.$range !== null; +}; + +export const isBytes = (value: object): value is { $bytes: string } => { + return "$bytes" in value && typeof value.$bytes === "string"; +}; + +export const isFuture = (value: object): value is { $future: string } => { + return "$future" in value && typeof value.$future === "string"; +}; + +export const isBoundIncluded = (value: object): value is { $boundIncluded: unknown } => { + return "$boundIncluded" in value; +}; + +export const isBoundExcluded = (value: object): value is { $boundExcluded: unknown } => { + return "$boundExcluded" in value; +}; + +export const isObject = (value: object): value is { $object: Record } => { + return "$object" in value && typeof value.$object === "object" && value.$object !== null; +}; diff --git a/packages/sqon/src/errors.ts b/packages/sqon/src/errors.ts new file mode 100644 index 00000000..a19e206d --- /dev/null +++ b/packages/sqon/src/errors.ts @@ -0,0 +1,47 @@ +/** + * The base error class for all SQON errors. + */ +export class SqonError extends Error {} + +/** + * Thrown when a parsed date or datetime is invalid + */ +export class InvalidDateError extends SqonError { + override name = "InvalidDateError"; + + constructor(dateOrMessage: Date | string) { + if (typeof dateOrMessage === "string") { + super(dateOrMessage); + } else { + super(`The provided date is invalid: ${dateOrMessage}`); + } + } +} + +/** + * Thrown when a RecordId or RecordIdRange is constructed with invalid parts + */ +export class InvalidRecordIdError extends SqonError { + override name = "InvalidRecordIdError"; +} + +/** + * Thrown when a Duration string cannot be parsed or a duration operation is invalid + */ +export class InvalidDurationError extends SqonError { + override name = "InvalidDurationError"; +} + +/** + * Thrown when a Decimal operation fails (division by zero, invalid input, etc.) + */ +export class InvalidDecimalError extends SqonError { + override name = "InvalidDecimalError"; +} + +/** + * Thrown when a Table or StringRecordId is constructed with an invalid value + */ +export class InvalidTableError extends SqonError { + override name = "InvalidTableError"; +} diff --git a/packages/sqon/src/index.ts b/packages/sqon/src/index.ts new file mode 100644 index 00000000..4d6002da --- /dev/null +++ b/packages/sqon/src/index.ts @@ -0,0 +1,10 @@ +export * from "./errors.ts"; +export * from "./types/index.ts"; +export * from "./utils/index.ts"; +export * from "./value/index.ts"; + +// Codecs + +export * from "./codec/cbor/index.ts"; +export * from "./codec/flatbuffer/index.ts"; +export * from "./codec/json/index.ts"; diff --git a/packages/sqon/src/internal/base64.ts b/packages/sqon/src/internal/base64.ts new file mode 100644 index 00000000..01127ddd --- /dev/null +++ b/packages/sqon/src/internal/base64.ts @@ -0,0 +1,21 @@ +export function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function fromBase64Url(str: string): Uint8Array { + const padded = str.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; +} diff --git a/packages/sdk/src/internal/escape-regex.ts b/packages/sqon/src/internal/escape-regex.ts similarity index 100% rename from packages/sdk/src/internal/escape-regex.ts rename to packages/sqon/src/internal/escape-regex.ts diff --git a/packages/sdk/src/internal/range.ts b/packages/sqon/src/internal/range.ts similarity index 95% rename from packages/sdk/src/internal/range.ts rename to packages/sqon/src/internal/range.ts index 8fd0f67a..37ec43b9 100644 --- a/packages/sdk/src/internal/range.ts +++ b/packages/sqon/src/internal/range.ts @@ -1,4 +1,4 @@ -import { type Bound, BoundExcluded, BoundIncluded } from "../utils/range"; +import { type Bound, BoundExcluded, BoundIncluded } from "../utils/range.ts"; export function getRangeJoin(beg: Bound, end: Bound): string { let output = ""; diff --git a/packages/sqon/src/internal/validation.ts b/packages/sqon/src/internal/validation.ts new file mode 100644 index 00000000..cbeb1c8f --- /dev/null +++ b/packages/sqon/src/internal/validation.ts @@ -0,0 +1,35 @@ +import { type Bound, BoundExcluded, BoundIncluded } from "../utils/range.ts"; +import { type RecordIdValue, Table, Uuid } from "../value/index.ts"; + +export function isValidIdPart(v: unknown): v is RecordIdValue { + if (v instanceof Uuid) return true; + + switch (typeof v) { + case "string": + case "number": + case "bigint": + return true; + case "object": + if (v === null) return false; + if (Array.isArray(v)) return true; + return isPlainObject(v); + default: + return false; + } +} + +export function isPlainObject(v: unknown): v is Record { + if (v === null || typeof v !== "object") return false; + const proto = Object.getPrototypeOf(v); + return proto === null || proto === Object.prototype; +} + +export function isValidIdBound(bound: unknown): bound is Bound { + return bound instanceof BoundIncluded || bound instanceof BoundExcluded + ? isValidIdPart(bound.value) + : true; +} + +export function isValidTable(tb: unknown): tb is string | Table { + return tb instanceof Table || typeof tb === "string"; +} diff --git a/packages/sqon/src/types/codec.ts b/packages/sqon/src/types/codec.ts new file mode 100644 index 00000000..a46504c1 --- /dev/null +++ b/packages/sqon/src/types/codec.ts @@ -0,0 +1,25 @@ +/** + * Options used to configure the value codec + */ +export interface CodecOptions { + /** Use native `Date` objects instead of custom `DateTime` objects. Using `Date` objects will result in a loss of nanosecond precision. */ + useNativeDates?: boolean; + /** Specify a custom visitor function to process encode values. */ + valueEncodeVisitor?: (value: unknown) => unknown; + /** Specify a custom visitor function to process decode values. */ + valueDecodeVisitor?: (value: unknown) => unknown; +} + +/** + * A codec for encoding and decoding SurrealQL values. + * + * The `Wire` generic controls the serialised format: + * - `Uint8Array` for binary codecs (CBOR, FlatBuffer) + * - `unknown` for structured codecs (JSON) that produce a plain object tree + * + * Defaults to `Uint8Array` for backward compatibility. + */ +export interface ValueCodec { + encode(data: T): Wire; + decode(data: Wire): T; +} diff --git a/packages/sqon/src/types/index.ts b/packages/sqon/src/types/index.ts new file mode 100644 index 00000000..bf8f93d8 --- /dev/null +++ b/packages/sqon/src/types/index.ts @@ -0,0 +1 @@ +export * from "./codec.ts"; diff --git a/packages/sqon/src/types/internal.ts b/packages/sqon/src/types/internal.ts new file mode 100644 index 00000000..55e15acb --- /dev/null +++ b/packages/sqon/src/types/internal.ts @@ -0,0 +1,25 @@ +/** + * Used to widen the type of a record id primitive value. + * + * @example + * ```ts + * new TypedRecordId("test", "123"); // TypedRecordId<"test", string> + * new TypedRecordId("test", 123); // TypedRecordId<"test", number> + * ``` + * + * Without widening the type would be: + * ```ts + * new TypedRecordId("test", "123"); // TypedRecordId<"test", "123"> + * new TypedRecordId("test", 123); // TypedRecordId<"test", 123> + * ``` + * + * Thus preventing us from declaring record ids in arrays or using them + * interchangeably. + */ +export type WidenRecordIdValue = T extends string + ? string + : T extends number + ? number + : T extends bigint + ? bigint + : T; diff --git a/packages/sdk/src/utils/equals.ts b/packages/sqon/src/utils/equals.ts similarity index 97% rename from packages/sdk/src/utils/equals.ts rename to packages/sqon/src/utils/equals.ts index 400bddbc..e955174e 100644 --- a/packages/sdk/src/utils/equals.ts +++ b/packages/sqon/src/utils/equals.ts @@ -1,4 +1,4 @@ -import { Value } from "../value/value"; +import { Value } from "../value/value.ts"; /** * Recursively compare supported SurrealQL values for equality. diff --git a/packages/sdk/src/utils/escape.ts b/packages/sqon/src/utils/escape.ts similarity index 85% rename from packages/sdk/src/utils/escape.ts rename to packages/sqon/src/utils/escape.ts index 9cc7c252..c96de979 100644 --- a/packages/sdk/src/utils/escape.ts +++ b/packages/sqon/src/utils/escape.ts @@ -1,7 +1,7 @@ -import { isValidIdPart } from "../internal/validation"; -import { Range, type RecordIdValue, Uuid } from "../value"; -import type { Bound } from "./range"; -import { toSurrealqlString } from "./to-surql-string"; +import { isValidIdPart } from "../internal/validation.ts"; +import { Range, type RecordIdValue, Uuid } from "../value/index.ts"; +import type { Bound } from "./range.ts"; +import { toSurrealqlString } from "./to-surql-string.ts"; const MAX_i64 = 9223372036854775807n; @@ -16,12 +16,10 @@ function isOnlyNumbers(str: string): boolean { * @returns Optionally escaped string */ export function escapeIdent(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}⟩`; } - // Empty string should always be escaped if (str === "") { return "⟨⟩"; } diff --git a/packages/sqon/src/utils/index.ts b/packages/sqon/src/utils/index.ts new file mode 100644 index 00000000..f3e58168 --- /dev/null +++ b/packages/sqon/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from "./equals.ts"; +export * from "./escape.ts"; +export * from "./jsonify.ts"; +export * from "./range.ts"; +export * from "./to-surql-string.ts"; diff --git a/packages/sdk/src/utils/jsonify.ts b/packages/sqon/src/utils/jsonify.ts similarity index 88% rename from packages/sdk/src/utils/jsonify.ts rename to packages/sqon/src/utils/jsonify.ts index 184b960b..10180eb3 100644 --- a/packages/sdk/src/utils/jsonify.ts +++ b/packages/sqon/src/utils/jsonify.ts @@ -11,9 +11,9 @@ import type { StringRecordId, Table, Uuid, -} from "../value"; +} from "../value/index.ts"; -import { Value } from "../value/value"; +import { Value } from "../value/value.ts"; export type Jsonify = T extends | Date @@ -54,12 +54,10 @@ 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 Value) { - return (input as unknown as { toJSON: () => unknown }).toJSON() as Jsonify; + 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); diff --git a/packages/sdk/src/utils/range.ts b/packages/sqon/src/utils/range.ts similarity index 96% rename from packages/sdk/src/utils/range.ts rename to packages/sqon/src/utils/range.ts index f4441896..53868be5 100644 --- a/packages/sdk/src/utils/range.ts +++ b/packages/sqon/src/utils/range.ts @@ -1,4 +1,4 @@ -import { BOUND_EXCLUDED_SYMBOL, BOUND_INCLUDED_SYMBOL, hasSymbol, markSymbol } from "./symbols"; +import { BOUND_EXCLUDED_SYMBOL, BOUND_INCLUDED_SYMBOL, hasSymbol, markSymbol } from "./symbols.ts"; /** * Represents a range bound which includes the value within the range diff --git a/packages/sqon/src/utils/symbols.ts b/packages/sqon/src/utils/symbols.ts new file mode 100644 index 00000000..ae41a609 --- /dev/null +++ b/packages/sqon/src/utils/symbols.ts @@ -0,0 +1,68 @@ +/** + * Symbol-based type checking utilities for handling multiple library instances. + * This allows instanceof-like checks to work across different instances/versions of the library. + */ + +const VALUE_SYMBOL = Symbol.for("surrealdb.Value"); +const RECORD_ID_SYMBOL = Symbol.for("surrealdb.RecordId"); +const STRING_RECORD_ID_SYMBOL = Symbol.for("surrealdb.StringRecordId"); +const RECORD_ID_RANGE_SYMBOL = Symbol.for("surrealdb.RecordIdRange"); +const UUID_SYMBOL = Symbol.for("surrealdb.Uuid"); +const DATETIME_SYMBOL = Symbol.for("surrealdb.DateTime"); +const DURATION_SYMBOL = Symbol.for("surrealdb.Duration"); +const DECIMAL_SYMBOL = Symbol.for("surrealdb.Decimal"); +const TABLE_SYMBOL = Symbol.for("surrealdb.Table"); +const RANGE_SYMBOL = Symbol.for("surrealdb.Range"); +const FUTURE_SYMBOL = Symbol.for("surrealdb.Future"); +const FILE_REF_SYMBOL = Symbol.for("surrealdb.FileRef"); +const GEOMETRY_SYMBOL = Symbol.for("surrealdb.Geometry"); +const GEOMETRY_POINT_SYMBOL = Symbol.for("surrealdb.GeometryPoint"); +const BOUND_INCLUDED_SYMBOL = Symbol.for("surrealdb.BoundIncluded"); +const BOUND_EXCLUDED_SYMBOL = Symbol.for("surrealdb.BoundExcluded"); +const GEOMETRY_LINE_SYMBOL = Symbol.for("surrealdb.GeometryLine"); +const GEOMETRY_POLYGON_SYMBOL = Symbol.for("surrealdb.GeometryPolygon"); +const GEOMETRY_MULTI_POINT_SYMBOL = Symbol.for("surrealdb.GeometryMultiPoint"); +const GEOMETRY_MULTI_LINE_SYMBOL = Symbol.for("surrealdb.GeometryMultiLine"); +const GEOMETRY_MULTI_POLYGON_SYMBOL = Symbol.for("surrealdb.GeometryMultiPolygon"); +const GEOMETRY_COLLECTION_SYMBOL = Symbol.for("surrealdb.GeometryCollection"); + +/** + * Helper function to mark an object with a symbol + */ +export function markSymbol(obj: unknown, symbol: symbol): void { + if (obj && typeof obj === "object") { + (obj as Record)[symbol] = true; + } +} + +/** + * Helper function to check if an object has a symbol + */ +export function hasSymbol(obj: unknown, symbol: symbol): boolean { + return !!(obj && typeof obj === "object" && (obj as Record)[symbol]); +} + +export { + VALUE_SYMBOL, + RECORD_ID_SYMBOL, + STRING_RECORD_ID_SYMBOL, + RECORD_ID_RANGE_SYMBOL, + UUID_SYMBOL, + DATETIME_SYMBOL, + DURATION_SYMBOL, + DECIMAL_SYMBOL, + TABLE_SYMBOL, + RANGE_SYMBOL, + FUTURE_SYMBOL, + FILE_REF_SYMBOL, + GEOMETRY_SYMBOL, + GEOMETRY_POINT_SYMBOL, + GEOMETRY_LINE_SYMBOL, + GEOMETRY_POLYGON_SYMBOL, + GEOMETRY_MULTI_POINT_SYMBOL, + GEOMETRY_MULTI_LINE_SYMBOL, + GEOMETRY_MULTI_POLYGON_SYMBOL, + GEOMETRY_COLLECTION_SYMBOL, + BOUND_INCLUDED_SYMBOL, + BOUND_EXCLUDED_SYMBOL, +}; diff --git a/packages/sdk/src/utils/to-surql-string.ts b/packages/sqon/src/utils/to-surql-string.ts similarity index 73% rename from packages/sdk/src/utils/to-surql-string.ts rename to packages/sqon/src/utils/to-surql-string.ts index 0dc1cffa..088e8a50 100644 --- a/packages/sdk/src/utils/to-surql-string.ts +++ b/packages/sqon/src/utils/to-surql-string.ts @@ -10,7 +10,7 @@ import { StringRecordId, Table, Uuid, -} from "../value"; +} from "../value/index.ts"; /** * Recursively convert any supported SurrealQL value into a string representation. @@ -24,19 +24,16 @@ export function toSurqlString(input: unknown): string { if (input === undefined) return "NONE"; if (typeof input === "object") { - if (input instanceof Uuid) - return `u${JSON.stringify((input as unknown as Uuid).toString())}`; + if (input instanceof Uuid) return `u${JSON.stringify(input.toString())}`; if (input instanceof Date || input instanceof DateTime) - return `d${JSON.stringify((input as unknown as DateTime).toISOString())}`; + return `d${JSON.stringify(input.toISOString())}`; if (input instanceof RecordId || input instanceof StringRecordId) - return `r${JSON.stringify((input as unknown as RecordId | StringRecordId).toString())}`; - if (input instanceof FileRef) - return `f${JSON.stringify((input as unknown as FileRef).toString())}`; + return `r${JSON.stringify(input.toString())}`; + if (input instanceof FileRef) return `f${JSON.stringify(input.toString())}`; - if (input instanceof Geometry) - return toSurqlString((input as unknown as Geometry).toJSON()); + if (input instanceof Geometry) return toSurqlString(input.toJSON()); - if (input instanceof Decimal) return `${(input as unknown as Decimal).toJSON()}dec`; + if (input instanceof Decimal) return `${input.toJSON()}dec`; if ( input instanceof Duration || @@ -44,10 +41,9 @@ export function toSurqlString(input: unknown): string { input instanceof Range || input instanceof Table ) { - return (input as unknown as { toJSON: () => string }).toJSON(); + return input.toString(); } - // We check by prototype, because we do not want to process derivatives of objects and arrays switch (Object.getPrototypeOf(input)) { case Object.prototype: { let output = "{ "; diff --git a/packages/sdk/src/value/datetime.ts b/packages/sqon/src/value/datetime.ts similarity index 73% rename from packages/sdk/src/value/datetime.ts rename to packages/sqon/src/value/datetime.ts index 070c9554..ba4898d2 100644 --- a/packages/sdk/src/value/datetime.ts +++ b/packages/sqon/src/value/datetime.ts @@ -1,7 +1,8 @@ -import { InvalidDateError } from "../errors"; -import { DATETIME_SYMBOL, hasSymbol, markSymbol } from "../utils/symbols"; -import { Duration } from "./duration"; -import { Value } from "./value"; +import { JsonCodec } from "../codec/json/codec.ts"; +import { InvalidDateError } from "../errors.ts"; +import { DATETIME_SYMBOL, hasSymbol, markSymbol } from "../utils/symbols.ts"; +import { Duration } from "./duration.ts"; +import { Value } from "./value.ts"; // Time unit definitions in nanoseconds const NANOSECOND = 1n; @@ -19,8 +20,8 @@ export class DateTime extends Value { return hasSymbol(instance, DATETIME_SYMBOL); } - readonly _seconds: bigint; - readonly _ns: bigint; + readonly #seconds: bigint; + readonly #nanoseconds: bigint; private static loadHr = typeof process !== "undefined" && process.hrtime @@ -76,56 +77,50 @@ export class DateTime extends Value { if (input === undefined) { const now = DateTime.now(); - this._seconds = now._seconds; - this._ns = now._ns; + this.#seconds = now.#seconds; + this.#nanoseconds = now.#nanoseconds; } else if (input instanceof DateTime) { - // Clone from existing datetime (cross-version safe via public fields) - const dt = input as unknown as DateTime; - this._seconds = dt._seconds; - this._ns = dt._ns; + this.#seconds = input.#seconds; + this.#nanoseconds = input.#nanoseconds; } else if (input instanceof Date) { - // Convert from JavaScript Date const time = input.getTime(); if (Number.isNaN(time)) { throw new InvalidDateError(input); } const s = BigInt(Math.floor(time / 1000)); const ns = BigInt((time % 1000) * 1000000); - this._seconds = s; - this._ns = ns; + this.#seconds = s; + this.#nanoseconds = ns; } else if (typeof input === "string") { - // Parse from ISO string or other datetime format const [s, ns] = DateTime.parseString(input); - this._seconds = s; - this._ns = ns; + this.#seconds = s; + this.#nanoseconds = ns; } else if (typeof input === "number") { - // Convert from Unix timestamp (seconds since epoch) - this._seconds = BigInt(Math.floor(input)); - this._ns = 0n; + this.#seconds = BigInt(Math.floor(input)); + this.#nanoseconds = 0n; } else if (typeof input === "bigint") { - // Convert from Unix timestamp (seconds since epoch) - this._seconds = input; - this._ns = 0n; + this.#seconds = input; + this.#nanoseconds = 0n; } else { - // Construct from tuple [seconds, nanoseconds] - const t = input as DateTimeTuple; - const s = typeof t[0] === "bigint" ? t[0] : BigInt(Math.floor(t[0] ?? 0)); - const ns = typeof t[1] === "bigint" ? t[1] : BigInt(Math.floor(t[1] ?? 0)); + const s = typeof input[0] === "bigint" ? input[0] : BigInt(Math.floor(input[0] ?? 0)); + const ns = typeof input[1] === "bigint" ? input[1] : BigInt(Math.floor(input[1] ?? 0)); - // Normalize nanoseconds to be within [0, 1 second) const totalSeconds = s + ns / SECOND; - this._seconds = totalSeconds; - this._ns = ns % SECOND; + this.#seconds = totalSeconds; + this.#nanoseconds = ns % SECOND; } markSymbol(this, DATETIME_SYMBOL); } equals(other: unknown): boolean { if (!(other instanceof DateTime)) return false; - return this.nanoseconds === (other as unknown as DateTime).nanoseconds; + return this.#seconds === other.#seconds && this.#nanoseconds === other.#nanoseconds; } - toJSON(): string { + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } return this.toISOString(); } @@ -140,28 +135,25 @@ export class DateTime extends Value { * Converts the datetime to a tuple */ toCompact(): [bigint, bigint] { - return [this._seconds, this._ns]; + return [this.#seconds, this.#nanoseconds]; } /** * Formats the datetime as an ISO 8601 string */ toISOString(): string { - // Calculate total milliseconds including nanosecond precision - const totalMilliseconds = Number(this._seconds) * 1000 + Number(this._ns) / 1000000; + const totalMilliseconds = + Number(this.#seconds) * 1000 + Number(this.#nanoseconds) / 1000000; const date = new Date(totalMilliseconds); const isoString = date.toISOString(); - if (this._ns === 0n) { + if (this.#nanoseconds === 0n) { return isoString; } - // Format nanoseconds as a single fractional part (up to 9 digits) - const nanoseconds = this._ns.toString().padStart(9, "0"); - // Remove trailing zeros + const nanoseconds = this.#nanoseconds.toString().padStart(9, "0"); const trimmed = nanoseconds.replace(/0+$/, ""); - // Replace the milliseconds part with the full nanosecond precision return isoString.replace(/\.\d{3}Z$/, `.${trimmed}Z`); } @@ -169,7 +161,8 @@ export class DateTime extends Value { * Converts to JavaScript Date object */ toDate(): Date { - const milliseconds = Number(this._seconds) * 1000 + Math.floor(Number(this._ns) / 1000000); + const milliseconds = + Number(this.#seconds) * 1000 + Math.floor(Number(this.#nanoseconds) / 1000000); return new Date(milliseconds); } @@ -180,14 +173,12 @@ export class DateTime extends Value { * @returns [seconds, nanoseconds] tuple */ static parseString(input: string): [bigint, bigint] { - // Handle ISO 8601 format: YYYY-MM-DDTHH:mm:ss.sssZ const isoRegex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?Z?$/; const match = input.match(isoRegex); if (match) { const [, year, month, day, hour, minute, second, fraction] = match; - // Parse the base time without fraction first const baseIsoString = `${year}-${month}-${day}T${hour}:${minute}:${second}Z`; const baseDate = new Date(baseIsoString); const baseTimestamp = baseDate.getTime(); @@ -200,16 +191,12 @@ export class DateTime extends Value { let nanoseconds = 0n; if (fraction) { - // Convert fraction to nanoseconds based on its length const fractionLength = fraction.length; if (fractionLength <= 3) { - // Milliseconds: convert to nanoseconds nanoseconds = BigInt(fraction.padEnd(3, "0")) * 1000000n; } else if (fractionLength <= 6) { - // Microseconds: convert to nanoseconds nanoseconds = BigInt(fraction.padEnd(6, "0")) * 1000n; } else { - // Already nanoseconds: pad to 9 digits nanoseconds = BigInt(fraction.padEnd(9, "0")); } } @@ -217,7 +204,6 @@ export class DateTime extends Value { return [seconds, nanoseconds]; } - // Try parsing as Unix timestamp const timestamp = Date.parse(input); if (!Number.isNaN(timestamp)) { @@ -237,8 +223,8 @@ export class DateTime extends Value { */ add(duration: Duration): DateTime { const [durSeconds, durNanoseconds] = duration.toCompact(); - let newSeconds = this._seconds + (durSeconds || 0n); - let newNanoseconds = this._ns + (durNanoseconds || 0n); + let newSeconds = this.#seconds + (durSeconds || 0n); + let newNanoseconds = this.#nanoseconds + (durNanoseconds || 0n); if (newNanoseconds >= SECOND) { newSeconds += 1n; @@ -256,8 +242,8 @@ export class DateTime extends Value { */ sub(duration: Duration): DateTime { const [durSeconds, durNanoseconds] = duration.toCompact(); - let newSeconds = this._seconds - (durSeconds || 0n); - let newNanoseconds = this._ns - (durNanoseconds || 0n); + let newSeconds = this.#seconds - (durSeconds || 0n); + let newNanoseconds = this.#nanoseconds - (durNanoseconds || 0n); if (newNanoseconds < 0n) { newSeconds -= 1n; @@ -273,8 +259,8 @@ export class DateTime extends Value { * @param other The other datetime */ diff(other: DateTime): Duration { - const totalThis = this.nanoseconds; - const totalOther = (other as unknown as DateTime).nanoseconds; + const totalThis = this.#seconds * SECOND + this.#nanoseconds; + const totalOther = other.#seconds * SECOND + other.#nanoseconds; const diff = totalThis > totalOther ? totalThis - totalOther : totalOther - totalThis; return Duration.nanoseconds(diff); @@ -294,32 +280,24 @@ export class DateTime extends Value { return 0; } - /** - * Total nanoseconds since Unix epoch - */ + /** Total nanoseconds since Unix epoch */ get nanoseconds(): bigint { - return this._seconds * SECOND + this._ns; + return this.#seconds * SECOND + this.#nanoseconds; } - /** - * Total microseconds since Unix epoch - */ + /** Total microseconds since Unix epoch */ get microseconds(): bigint { return this.nanoseconds / MICROSECOND; } - /** - * Total milliseconds since Unix epoch - */ + /** Total milliseconds since Unix epoch */ get milliseconds(): number { return Number(this.nanoseconds / MILLISECOND); } - /** - * Seconds since Unix epoch - */ + /** Seconds since Unix epoch */ get seconds(): number { - return Number(this._seconds); + return Number(this.#seconds); } /** @@ -366,7 +344,6 @@ export class DateTime extends Value { * Returns a new DateTime representing the current time */ static now(): DateTime { - // Use process.hrtime() for nanosecond precision if available (Node.js) if (DateTime.loadHr) { const diffNs = process.hrtime.bigint() - DateTime.loadHr.ns; const totalNanoseconds = DateTime.loadHr.ms * 1000000n + diffNs; @@ -375,16 +352,13 @@ export class DateTime extends Value { return new DateTime([seconds, nanoseconds]); } - // Use high-precision timing if the Performance API is available if (typeof performance !== "undefined" && performance.now && performance.timeOrigin) { const totalMilliseconds = performance.timeOrigin + performance.now(); const seconds = BigInt(Math.floor(totalMilliseconds / 1000)); - // performance.now() returns microseconds as float, so multiply by 1000 to get nanoseconds const nanoseconds = BigInt(Math.floor((totalMilliseconds % 1000) * 1000000)); return new DateTime([seconds, nanoseconds]); } - // Fallback to standard Date.now() const now = Date.now(); const seconds = BigInt(Math.floor(now / 1000)); const nanoseconds = BigInt((now % 1000) * 1000000); diff --git a/packages/sdk/src/value/decimal.ts b/packages/sqon/src/value/decimal.ts similarity index 73% rename from packages/sdk/src/value/decimal.ts rename to packages/sqon/src/value/decimal.ts index c83dc58a..63acfc78 100644 --- a/packages/sdk/src/value/decimal.ts +++ b/packages/sqon/src/value/decimal.ts @@ -1,6 +1,7 @@ -import { InvalidDecimalError } from "../errors"; -import { DECIMAL_SYMBOL, hasSymbol, markSymbol } from "../utils/symbols"; -import { Value } from "./value"; +import { JsonCodec } from "../codec/json/codec.ts"; +import { InvalidDecimalError } from "../errors.ts"; +import { DECIMAL_SYMBOL, hasSymbol, markSymbol } from "../utils/symbols.ts"; +import { Value } from "./value.ts"; export type DecimalTuple = [bigint, bigint, number]; @@ -12,9 +13,9 @@ export class Decimal extends Value { return hasSymbol(instance, DECIMAL_SYMBOL); } - readonly int: bigint; - readonly frac: bigint; - readonly scale: number; + readonly #int: bigint; + readonly #frac: bigint; + readonly #scale: number; /** * Constructs a new Decimal by cloning an existing Decimal @@ -49,88 +50,64 @@ export class Decimal extends Value { super(); if (input instanceof Decimal) { - // Clone from another Decimal (uses public getters for cross-version compatibility) - const dec = input as unknown as Decimal; - this.int = dec.int; - this.frac = dec.frac; - this.scale = dec.scale; - } else if (typeof input === "string") { - // Handle scientific notation before plain string parsing - if (/e/i.test(input)) { - const dec = Decimal.fromScientificNotation(input); - this.int = dec.int; - this.frac = dec.frac; - this.scale = dec.scale; - } else { - // Convert string/number to string and trim whitespace - const str = input.toString().trim(); - const isNegative = str.startsWith("-"); - const clean = isNegative ? str.slice(1) : str; - const [intStrRaw, fracStrRaw = ""] = clean.split("."); - - // Sanitize int/frac parts - const safeInt = /^\d+$/.test(intStrRaw) ? intStrRaw : "0"; - const safeFrac = /^\d+$/.test(fracStrRaw) ? fracStrRaw : "0"; - - // Calculate scale from fractional part length - const scale = safeFrac.length; - this.int = isNegative ? -BigInt(safeInt) : BigInt(safeInt); - this.frac = isNegative - ? -BigInt(safeFrac.padEnd(scale, "0")) - : BigInt(safeFrac.padEnd(scale, "0")); - this.scale = scale; - } - } else if (typeof input === "number") { - // Convert number to string and parse - const str = input.toString(); - const isNegative = str.startsWith("-"); - const clean = isNegative ? str.slice(1) : str; - const [intStrRaw, fracStrRaw = ""] = clean.split("."); - - const safeInt = /^\d+$/.test(intStrRaw) ? intStrRaw : "0"; - const safeFrac = /^\d+$/.test(fracStrRaw) ? fracStrRaw : "0"; - - const scale = safeFrac.length; - this.int = isNegative ? -BigInt(safeInt) : BigInt(safeInt); - this.frac = isNegative - ? -BigInt(safeFrac.padEnd(scale, "0")) - : BigInt(safeFrac.padEnd(scale, "0")); - this.scale = scale; + this.#int = input.#int; + this.#frac = input.#frac; + this.#scale = input.#scale; } else if (typeof input === "bigint") { - this.int = input; - this.frac = 0n; - this.scale = 0; + this.#int = input; + this.#frac = 0n; + this.#scale = 0; } else if (Array.isArray(input)) { - let int = BigInt(input[0]); - let frac = BigInt(input[1]); - const scale = input[2]; + let [int, frac, scale] = input; const maxFrac = 10n ** BigInt(scale); if (frac >= maxFrac) { int += frac / maxFrac; frac %= maxFrac; } - this.int = int; - this.frac = frac; - this.scale = scale; + this.#int = int; + this.#frac = frac; + this.#scale = scale; + } else if (typeof input === "string" && /e/i.test(input)) { + const dec = Decimal.fromScientificNotation(input); + this.#int = dec.#int; + this.#frac = dec.#frac; + this.#scale = dec.#scale; } else { - throw new InvalidDecimalError(String(input)); + const str = input.toString().trim(); + const isNegative = str.startsWith("-"); + const clean = isNegative ? str.slice(1) : str; + const [intStrRaw, fracStrRaw = ""] = clean.split("."); + + const safeInt = /^\d+$/.test(intStrRaw) ? intStrRaw : "0"; + const safeFrac = /^\d+$/.test(fracStrRaw) ? fracStrRaw : "0"; + + const intStr = safeInt || "0"; + const fracStr = safeFrac.padEnd(safeFrac.length || 1, "0"); + + const absInt = BigInt(intStr); + const absFrac = BigInt(fracStr); + + this.#int = isNegative ? -absInt : absInt; + this.#frac = isNegative ? -absFrac : absFrac; + this.#scale = safeFrac.length; } markSymbol(this, DECIMAL_SYMBOL); } equals(other: unknown): boolean { if (!(other instanceof Decimal)) return false; - const dec = other as unknown as Decimal; const a = this.toBigIntWithScale(); - const bScale = dec.scale; - const bVal = dec.int * 10n ** BigInt(bScale) + dec.frac; - const scale = Math.max(a.scale, bScale); + const b = other.toBigIntWithScale(); + const scale = Math.max(a.scale, b.scale); const aVal = a.value * 10n ** BigInt(scale - a.scale); - const bValScaled = bVal * 10n ** BigInt(scale - bScale); - return aVal === bValScaled; + const bVal = b.value * 10n ** BigInt(scale - b.scale); + return aVal === bVal; } - toJSON(): string { + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } return this.toString(); } @@ -139,21 +116,18 @@ export class Decimal extends Value { * trailing zeros in fractional part trimmed */ toString(): string { - const sign = this.int < 0n || this.frac < 0n ? "-" : ""; - const absInt = this.int < 0n ? -this.int : this.int; - const absFrac = this.frac < 0n ? -this.frac : this.frac; + const sign = this.#int < 0n || this.#frac < 0n ? "-" : ""; + const absInt = this.#int < 0n ? -this.#int : this.#int; + const absFrac = this.#frac < 0n ? -this.#frac : this.#frac; - if (this.scale === 0) { + if (this.#scale === 0) { return `${sign}${absInt}`; } - // Convert frac to string and pad it to match the scale - let fracStr = absFrac.toString().padStart(this.scale, "0"); + let fracStr = absFrac.toString().padStart(this.#scale, "0"); - // Trim trailing zeros without regex (avoids ReDoS) let end = fracStr.length; while (end > 0 && fracStr.charCodeAt(end - 1) === 48) { - // 48 === '0' end--; } fracStr = fracStr.slice(0, end); @@ -161,6 +135,21 @@ export class Decimal extends Value { return fracStr === "" ? `${sign}${absInt}` : `${sign}${absInt}.${fracStr}`; } + /** Returns the integer part of the number */ + get int(): bigint { + return this.#int; + } + + /** Returns the fractional part of the number */ + get frac(): bigint { + return this.#frac; + } + + /** Returns the scale (number of decimal places) */ + get scale(): number { + return this.#scale; + } + /** * Adds another Decimal to this one * @@ -267,11 +256,11 @@ export class Decimal extends Value { * @returns A new Decimal with non-negative components */ abs(): Decimal { - return this.int < 0n || this.frac < 0n + return this.#int < 0n || this.#frac < 0n ? new Decimal([ - this.int < 0n ? -this.int : this.int, - this.frac < 0n ? -this.frac : this.frac, - this.scale, + this.#int < 0n ? -this.#int : this.#int, + this.#frac < 0n ? -this.#frac : this.#frac, + this.#scale, ]) : this; } @@ -281,7 +270,7 @@ export class Decimal extends Value { * @returns A new Decimal with inverted sign */ neg(): Decimal { - return new Decimal([-this.int, -this.frac, this.scale]); + return new Decimal([-this.#int, -this.#frac, this.#scale]); } /** @@ -289,7 +278,7 @@ export class Decimal extends Value { * @returns True if both int and frac parts are zero */ isZero(): boolean { - return this.int === 0n && this.frac === 0n; + return this.#int === 0n && this.#frac === 0n; } /** @@ -297,7 +286,7 @@ export class Decimal extends Value { * @returns True if negative */ isNegative(): boolean { - return this.int < 0n || (this.int === 0n && this.frac < 0n); + return this.#int < 0n || (this.#int === 0n && this.#frac < 0n); } /** @@ -328,17 +317,15 @@ export class Decimal extends Value { const full = this.toBigIntWithScale(); - // If current scale is already less than or equal to target precision - if (this.scale <= precision) { - const factor = 10n ** BigInt(precision - this.scale); + if (this.#scale <= precision) { + const factor = 10n ** BigInt(precision - this.#scale); const newValue = full.value * factor; const intPart = newValue / 10n ** BigInt(precision); const fracPart = newValue % 10n ** BigInt(precision); return new Decimal([intPart, fracPart, precision]); } - // Round by removing digits past target precision - const factor = 10n ** BigInt(this.scale - precision); + const factor = 10n ** BigInt(this.#scale - precision); const half = factor / 2n; const rounded = full.value >= 0n ? (full.value + half) / factor : (full.value - half) / factor; @@ -379,9 +366,9 @@ export class Decimal extends Value { * @returns An bigint approximation (may lose precision) */ toBigInt(): bigint { - if (this.int >= 0n) return this.int; - if (this.frac !== 0n) return this.int - 1n; - return this.int; + if (this.#int >= 0n) return this.#int; + if (this.#frac !== 0n) return this.#int - 1n; + return this.#int; } /** @@ -390,9 +377,9 @@ export class Decimal extends Value { */ toParts(): { int: bigint; frac: bigint; scale: number } { return { - int: this.int, - frac: this.frac, - scale: this.scale, + int: this.#int, + frac: this.#frac, + scale: this.#scale, }; } @@ -434,12 +421,10 @@ export class Decimal extends Value { static fromScientificNotation(input: string): Decimal { const trimmed = input.trim(); - // Cheap validation: basic format check without overlapping quantifiers if (!/^[+-]?\d+(\.\d+)?[eE][+-]?\d+$/.test(trimmed)) { throw new InvalidDecimalError(`Invalid scientific notation: ${input}`); } - // Safe and predictable manual split const [baseStr, expStr] = trimmed.split(/[eE]/); const exp = Number.parseInt(expStr, 10); const negative = baseStr.startsWith("-"); @@ -467,8 +452,8 @@ export class Decimal extends Value { private toBigIntWithScale(): { value: bigint; scale: number } { return { - value: this.int * 10n ** BigInt(this.scale) + this.frac, - scale: this.scale, + value: this.#int * 10n ** BigInt(this.#scale) + this.#frac, + scale: this.#scale, }; } } diff --git a/packages/sdk/src/value/duration.ts b/packages/sqon/src/value/duration.ts similarity index 75% rename from packages/sdk/src/value/duration.ts rename to packages/sqon/src/value/duration.ts index ebc057cf..a410d0e9 100644 --- a/packages/sdk/src/value/duration.ts +++ b/packages/sqon/src/value/duration.ts @@ -1,11 +1,11 @@ -import { InvalidDurationError } from "../errors"; -import { escapeRegex } from "../internal/escape-regex"; -import { DURATION_SYMBOL, hasSymbol, markSymbol } from "../utils/symbols"; -import { Value } from "./value"; +import { JsonCodec } from "../codec/json/codec.ts"; +import { InvalidDurationError } from "../errors.ts"; +import { escapeRegex } from "../internal/escape-regex.ts"; +import { DURATION_SYMBOL, hasSymbol, markSymbol } from "../utils/symbols.ts"; +import { Value } from "./value.ts"; export type DurationTuple = [number | bigint, number | bigint] | [number | bigint] | []; -// Time unit definitions in nanoseconds const NANOSECOND = 1n; const MICROSECOND = 1000n * NANOSECOND; const MILLISECOND = 1000n * MICROSECOND; @@ -16,12 +16,11 @@ const DAY = 24n * HOUR; const WEEK = 7n * DAY; const YEAR = 365n * DAY; -// Unit string to nanosecond mapping const UNITS = new Map([ ["ns", NANOSECOND], - ["\u00b5s", MICROSECOND], // micro (Greek letter mu) - ["\u03bcs", MICROSECOND], // micro (Greek letter mu variant) - ["us", MICROSECOND], // ASCII fallback + ["\u00b5s", MICROSECOND], + ["\u03bcs", MICROSECOND], + ["us", MICROSECOND], ["ms", MILLISECOND], ["s", SECOND], ["m", MINUTE], @@ -31,18 +30,15 @@ const UNITS = new Map([ ["y", YEAR], ]); -// Reversed mapping of nanoseconds to unit string const UNITS_REVERSED = Array.from(UNITS).reduce((map, [unit, size]) => { map.set(size, unit); return map; }, new Map()); -// Regex for parsing duration parts like "3h" or "15ms" const DURATION_PART_REGEX = new RegExp( `^(\\d+)\\.?\\d*(${Array.from(UNITS.keys()).map(escapeRegex).join("|")})`, ); -// Regex for parsing a single float duration like "1.5s" or "500.0ms" const FLOAT_DURATION_REGEX = new RegExp( `^(\\d+(?:\\.\\d+)?)(${Array.from(UNITS.keys()).map(escapeRegex).join("|")})$`, ); @@ -55,8 +51,8 @@ export class Duration extends Value { return hasSymbol(instance, DURATION_SYMBOL); } - readonly _seconds: bigint; - readonly _ns: bigint; + readonly #seconds: bigint; + readonly #nanoseconds: bigint; /** * Constructs a new Duration by cloning an existing duration @@ -80,50 +76,35 @@ export class Duration extends Value { constructor(input: string); // Shadow implementation - constructor(input?: Duration | DurationTuple | string | number | bigint) { + constructor(input: Duration | DurationTuple | string) { super(); - if (input === undefined) { - this._seconds = 0n; - this._ns = 0n; - } else if (input instanceof Duration) { - // Clone from existing duration (uses public getter for cross-version compatibility) - const totalNs = (input as unknown as Duration).nanoseconds; - this._seconds = totalNs / SECOND; - this._ns = totalNs % SECOND; + if (input instanceof Duration) { + this.#seconds = input.#seconds; + this.#nanoseconds = input.#nanoseconds; } else if (typeof input === "string") { - // Parse from a human-readable string like "1h30m" const [s, ns] = Duration.parseString(input); - this._seconds = s; - this._ns = ns; - } else if (typeof input === "number" || typeof input === "bigint") { - const total = BigInt(input); - this._seconds = total / SECOND; - this._ns = total % SECOND; - } else if (Array.isArray(input)) { - const [seconds, nanoseconds] = input; - const s = typeof seconds === "bigint" ? seconds : BigInt(Math.floor(seconds ?? 0)); - const ns = - typeof nanoseconds === "bigint" - ? nanoseconds - : BigInt(Math.floor(nanoseconds ?? 0)); - const total = s * SECOND + ns; - // Normalize total into separate seconds and nanoseconds fields - this._seconds = total / SECOND; - this._ns = total % SECOND; + this.#seconds = s; + this.#nanoseconds = ns; } else { - this._seconds = 0n; - this._ns = 0n; + const s = typeof input[0] === "bigint" ? input[0] : BigInt(Math.floor(input[0] ?? 0)); + const ns = typeof input[1] === "bigint" ? input[1] : BigInt(Math.floor(input[1] ?? 0)); + const total = s * SECOND + ns; + this.#seconds = total / SECOND; + this.#nanoseconds = total % SECOND; } markSymbol(this, DURATION_SYMBOL); } equals(other: unknown): boolean { if (!(other instanceof Duration)) return false; - return this.nanoseconds === (other as unknown as Duration).nanoseconds; + return this.#seconds === other.#seconds && this.#nanoseconds === other.#nanoseconds; } - toJSON(): string { + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } return this.toString(); } @@ -131,10 +112,9 @@ export class Duration extends Value { * @returns Human readable duration string */ toString(): string { - let remainingSeconds = this._seconds; + let remainingSeconds = this.#seconds; let result = ""; - // Convert seconds into largest possible whole units (≥ 1s) for (const [size, unit] of Array.from(UNITS_REVERSED).reverse()) { if (size >= SECOND) { const amount = remainingSeconds / (size / SECOND); @@ -145,10 +125,8 @@ export class Duration extends Value { } } - // Convert remaining seconds to nanoseconds - let remainingNanoseconds = remainingSeconds * SECOND + this._ns; + let remainingNanoseconds = remainingSeconds * SECOND + this.#nanoseconds; - // Convert sub-second nanoseconds to units < 1s for (const [size, unit] of Array.from(UNITS_REVERSED).reverse()) { if (size < SECOND) { const amount = remainingNanoseconds / size; @@ -159,17 +137,17 @@ export class Duration extends Value { } } - return result; + return result || "0ns"; } /** * Converts the duration to a tuple */ toCompact(): [bigint, bigint] | [bigint] | [] { - return this._ns > 0n - ? [this._seconds, this._ns] - : this._seconds > 0n - ? [this._seconds] + return this.#nanoseconds > 0n + ? [this.#seconds, this.#nanoseconds] + : this.#seconds > 0n + ? [this.#seconds] : []; } @@ -184,7 +162,6 @@ export class Duration extends Value { let nanoseconds = 0n; let left = input; - // Loop through string and extract valid duration parts while (left !== "") { const match = left.match(DURATION_PART_REGEX); if (match) { @@ -194,21 +171,17 @@ export class Duration extends Value { if (!factor) throw new InvalidDurationError(`Invalid duration unit: ${unit}`); if (factor >= SECOND) { - // Accumulate seconds seconds += amount * (factor / SECOND); } else { - // Accumulate nanoseconds nanoseconds += amount * factor; } - // Slice the processed segment off left = left.slice(match[0].length); } else { throw new InvalidDurationError("Could not match a next duration part"); } } - // Normalize: convert overflow nanoseconds to seconds seconds += nanoseconds / SECOND; nanoseconds %= SECOND; return [seconds, nanoseconds]; @@ -221,8 +194,13 @@ export class Duration extends Value { * @returns The resulting duration */ add(other: Duration): Duration { - const totalNs = this.nanoseconds + (other as unknown as Duration).nanoseconds; - return new Duration([totalNs / SECOND, totalNs % SECOND]); + let sec = this.#seconds + other.#seconds; + let ns = this.#nanoseconds + other.#nanoseconds; + if (ns >= SECOND) { + sec += 1n; + ns -= SECOND; + } + return new Duration([sec, ns]); } /** @@ -232,8 +210,13 @@ export class Duration extends Value { * @returns The resulting duration */ sub(other: Duration): Duration { - const totalNs = this.nanoseconds - (other as unknown as Duration).nanoseconds; - return new Duration([totalNs / SECOND, totalNs % SECOND]); + let sec = this.#seconds - other.#seconds; + let ns = this.#nanoseconds - other.#nanoseconds; + if (ns < 0n) { + sec -= 1n; + ns += SECOND; + } + return new Duration([sec, ns]); } /** @@ -244,7 +227,7 @@ export class Duration extends Value { */ mul(factor: number | bigint): Duration { const factorBig = typeof factor === "bigint" ? factor : BigInt(Math.floor(factor)); - const totalNs = this._seconds * SECOND + this._ns; + const totalNs = this.#seconds * SECOND + this.#nanoseconds; const resultNs = totalNs * factorBig; return new Duration([resultNs / SECOND, resultNs % SECOND]); } @@ -259,15 +242,14 @@ export class Duration extends Value { div(divisor: number | bigint): Duration; div(divisor: number | bigint | Duration): bigint | Duration { if (typeof divisor === "object" && divisor instanceof Duration) { - const a = this.nanoseconds; - const b = (divisor as unknown as Duration).nanoseconds; + const a = this.#seconds * SECOND + this.#nanoseconds; + const b = divisor.#seconds * SECOND + divisor.#nanoseconds; if (b === 0n) throw new InvalidDurationError("Division by zero duration"); return a / b; } - const divisorBig = - typeof divisor === "bigint" ? divisor : BigInt(Math.floor(divisor as number)); + const divisorBig = typeof divisor === "bigint" ? divisor : BigInt(Math.floor(divisor)); if (divisorBig === 0n) throw new InvalidDurationError("Division by zero"); - const totalNs = this._seconds * SECOND + this._ns; + const totalNs = this.#seconds * SECOND + this.#nanoseconds; const resultNs = totalNs / divisorBig; return new Duration([resultNs / SECOND, resultNs % SECOND]); } @@ -279,8 +261,8 @@ export class Duration extends Value { * @returns The remainder duration */ mod(mod: Duration): Duration { - const a = this.nanoseconds; - const b = (mod as unknown as Duration).nanoseconds; + const a = this.#seconds * SECOND + this.#nanoseconds; + const b = mod.#seconds * SECOND + mod.#nanoseconds; if (b === 0n) throw new InvalidDurationError("Modulo by zero duration"); const resultNs = a % b; return new Duration([resultNs / SECOND, resultNs % SECOND]); @@ -290,7 +272,7 @@ export class Duration extends Value { * Total nanoseconds in this duration */ get nanoseconds(): bigint { - return this._seconds * SECOND + this._ns; + return this.#seconds * SECOND + this.#nanoseconds; } /** @@ -311,42 +293,42 @@ export class Duration extends Value { * Whole seconds in the duration */ get seconds(): bigint { - return this._seconds; + return this.#seconds; } /** * Total whole minutes in the duration */ get minutes(): bigint { - return this._seconds / (MINUTE / SECOND); + return this.#seconds / (MINUTE / SECOND); } /** * Total whole hours in the duration */ get hours(): bigint { - return this._seconds / (HOUR / SECOND); + return this.#seconds / (HOUR / SECOND); } /** * Total whole days in the duration */ get days(): bigint { - return this._seconds / (DAY / SECOND); + return this.#seconds / (DAY / SECOND); } /** * Total whole weeks in the duration */ get weeks(): bigint { - return this._seconds / (WEEK / SECOND); + return this.#seconds / (WEEK / SECOND); } /** * Total whole years in the duration */ get years(): bigint { - return this._seconds / (YEAR / SECOND); + return this.#seconds / (YEAR / SECOND); } /** diff --git a/packages/sdk/src/value/file.ts b/packages/sqon/src/value/file.ts similarity index 66% rename from packages/sdk/src/value/file.ts rename to packages/sqon/src/value/file.ts index 8bd4a22a..f01748b7 100644 --- a/packages/sdk/src/value/file.ts +++ b/packages/sqon/src/value/file.ts @@ -1,5 +1,6 @@ -import { FILE_REF_SYMBOL, hasSymbol, markSymbol } from "../utils/symbols"; -import { Value } from "./value"; +import { JsonCodec } from "../codec/json/codec.ts"; +import { FILE_REF_SYMBOL, hasSymbol, markSymbol } from "../utils/symbols.ts"; +import { Value } from "./value.ts"; /** * A SurrealQL file reference value. @@ -9,28 +10,38 @@ export class FileRef extends Value { return hasSymbol(instance, FILE_REF_SYMBOL); } - readonly bucket: string; - readonly key: string; + readonly #bucket: string; + readonly #key: string; constructor(bucket: string, key: string) { super(); - this.bucket = bucket; - this.key = key.startsWith("/") ? key : `/${key}`; + this.#bucket = bucket; + this.#key = key.startsWith("/") ? key : `/${key}`; markSymbol(this, FILE_REF_SYMBOL); } + get bucket(): string { + return this.#bucket; + } + + get key(): string { + return this.#key; + } + equals(other: unknown): boolean { if (!(other instanceof FileRef)) return false; - const o = other as unknown as FileRef; - return this.bucket === o.bucket && this.key === o.key; + return this.#bucket === other.#bucket && this.#key === other.#key; } - toJSON(): string { + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } return this.toString(); } toString(): string { - return `${fmtInner(this.bucket, true)}:${fmtInner(this.key, false)}`; + return `${fmtInner(this.#bucket, true)}:${fmtInner(this.#key, false)}`; } } @@ -41,7 +52,6 @@ export function fmtInner(str: string, escapeSlash: boolean): string { const char = str[i]; const code = str.charCodeAt(i); - // Check if character is allowed if ( (code >= 48 && code <= 57) || // numeric (0-9) (code >= 65 && code <= 90) || // upper alpha (A-Z) diff --git a/packages/sdk/src/value/future.ts b/packages/sqon/src/value/future.ts similarity index 58% rename from packages/sdk/src/value/future.ts rename to packages/sqon/src/value/future.ts index b094d148..2695bb97 100644 --- a/packages/sdk/src/value/future.ts +++ b/packages/sqon/src/value/future.ts @@ -1,5 +1,6 @@ -import { FUTURE_SYMBOL, hasSymbol, markSymbol } from "../utils/symbols"; -import { Value } from "./value"; +import { JsonCodec } from "../codec/json/codec.ts"; +import { FUTURE_SYMBOL, hasSymbol, markSymbol } from "../utils/symbols.ts"; +import { Value } from "./value.ts"; /** * An uncomputed SurrealQL future value. @@ -11,20 +12,23 @@ export class Future extends Value { return hasSymbol(instance, FUTURE_SYMBOL); } - readonly body: string; + readonly #body: string; constructor(body: string) { super(); - this.body = body; + this.#body = body; markSymbol(this, FUTURE_SYMBOL); } equals(other: unknown): boolean { if (!(other instanceof Future)) return false; - return this.body === (other as unknown as Future).body; + return this.#body === other.#body; } - toJSON(): string { + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } return this.toString(); } @@ -32,6 +36,13 @@ export class Future extends Value { * @returns The uncomputed future notation */ toString(): string { - return ` ${this.body}`; + return ` ${this.#body}`; + } + + /** + * The body of the future + */ + get body(): string { + return this.#body; } } diff --git a/packages/sdk/src/value/geometry.ts b/packages/sqon/src/value/geometry.ts similarity index 54% rename from packages/sdk/src/value/geometry.ts rename to packages/sqon/src/value/geometry.ts index bcd8ec57..8be85d14 100644 --- a/packages/sdk/src/value/geometry.ts +++ b/packages/sqon/src/value/geometry.ts @@ -9,7 +9,7 @@ import { GEOMETRY_SYMBOL, hasSymbol, markSymbol, -} from "../utils/symbols"; +} from "../utils/symbols.ts"; import { Decimal } from "./decimal.ts"; import { Value } from "./value.ts"; @@ -25,23 +25,41 @@ export abstract class Geometry extends Value { super(); markSymbol(this, GEOMETRY_SYMBOL); } + abstract override toJSON(): GeoJson; abstract is(geometry: Geometry): boolean; abstract clone(): Geometry; equals(other: unknown): boolean { if (!(other instanceof Geometry)) return false; - return this.is(other as unknown as Geometry); + return this.is(other); } toString(): string { return JSON.stringify(this.toJSON()); } -} -function f(num: number | Decimal): number { - if (num instanceof Decimal) return (num as unknown as Decimal).toFloat(); - return num as number; + /** + * Create a Geometry instance from a GeoJSON object. + */ + static fromJSON(json: GeoJson): Geometry { + switch (json.type) { + case "Point": + return new GeometryPoint(json); + case "LineString": + return new GeometryLine(json); + case "Polygon": + return new GeometryPolygon(json); + case "MultiPoint": + return new GeometryMultiPoint(json); + case "MultiLineString": + return new GeometryMultiLine(json); + case "MultiPolygon": + return new GeometryMultiPolygon(json); + case "GeometryCollection": + return new GeometryCollection(json); + } + } } /** @@ -54,13 +72,20 @@ export class GeometryPoint extends Geometry { readonly point: [number, number]; - constructor(point: [number | Decimal, number | Decimal] | GeometryPoint) { + /** Construct from a coordinate pair. */ + constructor(point: [number | Decimal, number | Decimal]); + /** Construct from a GeoJSON Point object. */ + constructor(json: GeoJsonPoint); + /** Clone an existing GeometryPoint. */ + constructor(source: GeometryPoint); + constructor(input: [number | Decimal, number | Decimal] | GeoJsonPoint | GeometryPoint) { super(); - if (point instanceof GeometryPoint) { - this.point = (point as unknown as GeometryPoint).clone().point; + if (input instanceof GeometryPoint) { + this.point = [...input.point]; + } else if (Array.isArray(input)) { + this.point = [f(input[0]), f(input[1])]; } else { - const arr = point as [number | Decimal, number | Decimal]; - this.point = [f(arr[0]), f(arr[1])]; + this.point = [...input.coordinates]; } markSymbol(this, GEOMETRY_POINT_SYMBOL); } @@ -78,8 +103,7 @@ export class GeometryPoint extends Geometry { is(geometry: Geometry): geometry is GeometryPoint { if (!(geometry instanceof GeometryPoint)) return false; - const gp = geometry as unknown as GeometryPoint; - return this.point[0] === gp.point[0] && this.point[1] === gp.point[1]; + return this.point[0] === geometry.point[0] && this.point[1] === geometry.point[1]; } clone(): GeometryPoint { @@ -97,14 +121,30 @@ 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) { + /** Construct from an array of points. */ + constructor(line: [GeometryPoint, GeometryPoint, ...GeometryPoint[]]); + /** Construct from a GeoJSON LineString object. */ + constructor(json: GeoJsonLineString); + /** Clone an existing GeometryLine. */ + constructor(source: GeometryLine); + constructor( + input: + | [GeometryPoint, GeometryPoint, ...GeometryPoint[]] + | GeoJsonLineString + | GeometryLine, + ) { super(); - this.line = - line instanceof GeometryLine - ? (line as unknown as GeometryLine).clone().line - : (line as [GeometryPoint, GeometryPoint, ...GeometryPoint[]]); + if (input instanceof GeometryLine) { + this.line = [...input.line]; + } else if (Array.isArray(input)) { + this.line = input; + } else { + this.line = input.coordinates.map((c) => new GeometryPoint(c)) as [ + GeometryPoint, + GeometryPoint, + ...GeometryPoint[], + ]; + } markSymbol(this, GEOMETRY_LINE_SYMBOL); } @@ -127,10 +167,9 @@ export class GeometryLine extends Geometry { is(geometry: Geometry): geometry is GeometryLine { if (!(geometry instanceof GeometryLine)) return false; - const gl = geometry as unknown as GeometryLine; - if (this.line.length !== gl.line.length) 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(gl.line[i])) return false; + if (!this.line[i].is(geometry.line[i])) return false; } return true; @@ -153,16 +192,35 @@ export class GeometryPolygon extends Geometry { readonly polygon: [GeometryLine, ...GeometryLine[]]; - constructor(polygon: [GeometryLine, ...GeometryLine[]] | GeometryPolygon) { + /** Construct from an array of line rings. */ + constructor(polygon: [GeometryLine, ...GeometryLine[]]); + /** Construct from a GeoJSON Polygon object. */ + constructor(json: GeoJsonPolygon); + /** Clone an existing GeometryPolygon. */ + constructor(source: GeometryPolygon); + constructor(input: [GeometryLine, ...GeometryLine[]] | GeoJsonPolygon | GeometryPolygon) { super(); - this.polygon = - polygon instanceof GeometryPolygon - ? (polygon as unknown as GeometryPolygon).clone().polygon - : ((polygon as [GeometryLine, ...GeometryLine[]]).map((l) => { - const line = l.clone(); - line.close(); - return line; - }) as [GeometryLine, ...GeometryLine[]]); + if (input instanceof GeometryPolygon) { + this.polygon = [...input.polygon]; + } else if (Array.isArray(input)) { + this.polygon = input.map((l) => { + const line = l.clone(); + line.close(); + return line; + }) as [GeometryLine, ...GeometryLine[]]; + } else { + this.polygon = input.coordinates.map((ring) => { + const line = new GeometryLine( + ring.map((c) => new GeometryPoint(c)) as [ + GeometryPoint, + GeometryPoint, + ...GeometryPoint[], + ], + ); + line.close(); + return line; + }) as [GeometryLine, ...GeometryLine[]]; + } markSymbol(this, GEOMETRY_POLYGON_SYMBOL); } @@ -179,10 +237,9 @@ export class GeometryPolygon extends Geometry { is(geometry: Geometry): geometry is GeometryPolygon { if (!(geometry instanceof GeometryPolygon)) return false; - const gp = geometry as unknown as GeometryPolygon; - if (this.polygon.length !== gp.polygon.length) 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(gp.polygon[i])) return false; + if (!this.polygon[i].is(geometry.polygon[i])) return false; } return true; @@ -205,12 +262,26 @@ export class GeometryMultiPoint extends Geometry { readonly points: [GeometryPoint, ...GeometryPoint[]]; - constructor(points: [GeometryPoint, ...GeometryPoint[]] | GeometryMultiPoint) { + /** Construct from an array of points. */ + constructor(points: [GeometryPoint, ...GeometryPoint[]]); + /** Construct from a GeoJSON MultiPoint object. */ + constructor(json: GeoJsonMultiPoint); + /** Clone an existing GeometryMultiPoint. */ + constructor(source: GeometryMultiPoint); + constructor( + input: [GeometryPoint, ...GeometryPoint[]] | GeoJsonMultiPoint | GeometryMultiPoint, + ) { super(); - this.points = - points instanceof GeometryMultiPoint - ? (points as unknown as GeometryMultiPoint).points - : (points as [GeometryPoint, ...GeometryPoint[]]); + if (input instanceof GeometryMultiPoint) { + this.points = [...input.points]; + } else if (Array.isArray(input)) { + this.points = input; + } else { + this.points = input.coordinates.map((c) => new GeometryPoint(c)) as [ + GeometryPoint, + ...GeometryPoint[], + ]; + } markSymbol(this, GEOMETRY_MULTI_POINT_SYMBOL); } @@ -227,10 +298,9 @@ export class GeometryMultiPoint extends Geometry { is(geometry: Geometry): geometry is GeometryMultiPoint { if (!(geometry instanceof GeometryMultiPoint)) return false; - const gmp = geometry as unknown as GeometryMultiPoint; - if (this.points.length !== gmp.points.length) 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(gmp.points[i])) return false; + if (!this.points[i].is(geometry.points[i])) return false; } return true; @@ -253,12 +323,32 @@ export class GeometryMultiLine extends Geometry { readonly lines: [GeometryLine, ...GeometryLine[]]; - constructor(lines: [GeometryLine, ...GeometryLine[]] | GeometryMultiLine) { + /** Construct from an array of lines. */ + constructor(lines: [GeometryLine, ...GeometryLine[]]); + /** Construct from a GeoJSON MultiLineString object. */ + constructor(json: GeoJsonMultiLineString); + /** Clone an existing GeometryMultiLine. */ + constructor(source: GeometryMultiLine); + constructor( + input: [GeometryLine, ...GeometryLine[]] | GeoJsonMultiLineString | GeometryMultiLine, + ) { super(); - this.lines = - lines instanceof GeometryMultiLine - ? (lines as unknown as GeometryMultiLine).lines - : (lines as [GeometryLine, ...GeometryLine[]]); + if (input instanceof GeometryMultiLine) { + this.lines = [...input.lines]; + } else if (Array.isArray(input)) { + this.lines = input; + } else { + this.lines = input.coordinates.map( + (coords) => + new GeometryLine( + coords.map((c) => new GeometryPoint(c)) as [ + GeometryPoint, + GeometryPoint, + ...GeometryPoint[], + ], + ), + ) as [GeometryLine, ...GeometryLine[]]; + } markSymbol(this, GEOMETRY_MULTI_LINE_SYMBOL); } @@ -275,10 +365,9 @@ export class GeometryMultiLine extends Geometry { is(geometry: Geometry): geometry is GeometryMultiLine { if (!(geometry instanceof GeometryMultiLine)) return false; - const gml = geometry as unknown as GeometryMultiLine; - if (this.lines.length !== gml.lines.length) 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(gml.lines[i])) return false; + if (!this.lines[i].is(geometry.lines[i])) return false; } return true; @@ -301,12 +390,37 @@ export class GeometryMultiPolygon extends Geometry { readonly polygons: [GeometryPolygon, ...GeometryPolygon[]]; - constructor(polygons: [GeometryPolygon, ...GeometryPolygon[]] | GeometryMultiPolygon) { + /** Construct from an array of polygons. */ + constructor(polygons: [GeometryPolygon, ...GeometryPolygon[]]); + /** Construct from a GeoJSON MultiPolygon object. */ + constructor(json: GeoJsonMultiPolygon); + /** Clone an existing GeometryMultiPolygon. */ + constructor(source: GeometryMultiPolygon); + constructor( + input: [GeometryPolygon, ...GeometryPolygon[]] | GeoJsonMultiPolygon | GeometryMultiPolygon, + ) { super(); - this.polygons = - polygons instanceof GeometryMultiPolygon - ? (polygons as unknown as GeometryMultiPolygon).polygons - : (polygons as [GeometryPolygon, ...GeometryPolygon[]]); + if (input instanceof GeometryMultiPolygon) { + this.polygons = [...input.polygons]; + } else if (Array.isArray(input)) { + this.polygons = input; + } else { + this.polygons = input.coordinates.map( + (rings) => + new GeometryPolygon( + rings.map( + (ring) => + new GeometryLine( + ring.map((c) => new GeometryPoint(c)) as [ + GeometryPoint, + GeometryPoint, + ...GeometryPoint[], + ], + ), + ) as [GeometryLine, ...GeometryLine[]], + ), + ) as [GeometryPolygon, ...GeometryPolygon[]]; + } markSymbol(this, GEOMETRY_MULTI_POLYGON_SYMBOL); } @@ -323,10 +437,9 @@ export class GeometryMultiPolygon extends Geometry { is(geometry: Geometry): geometry is GeometryMultiPolygon { if (!(geometry instanceof GeometryMultiPolygon)) return false; - const gmp = geometry as unknown as GeometryMultiPolygon; - if (this.polygons.length !== gmp.polygons.length) 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(gmp.polygons[i])) return false; + if (!this.polygons[i].is(geometry.polygons[i])) return false; } return true; @@ -349,12 +462,24 @@ export class GeometryCollection extends Geometry { readonly collection: [Geometry, ...Geometry[]]; - constructor(collection: [Geometry, ...Geometry[]] | GeometryCollection) { + /** Construct from an array of geometries. */ + constructor(collection: [Geometry, ...Geometry[]]); + /** Construct from a GeoJSON GeometryCollection object. */ + constructor(json: GeoJsonCollection); + /** Clone an existing GeometryCollection. */ + constructor(source: GeometryCollection); + constructor(input: [Geometry, ...Geometry[]] | GeoJsonCollection | GeometryCollection) { super(); - this.collection = - collection instanceof GeometryCollection - ? (collection as unknown as GeometryCollection).collection - : (collection as [Geometry, ...Geometry[]]); + if (input instanceof GeometryCollection) { + this.collection = [...input.collection]; + } else if (Array.isArray(input)) { + this.collection = input; + } else { + this.collection = input.geometries.map((g) => Geometry.fromJSON(g)) as [ + Geometry, + ...Geometry[], + ]; + } markSymbol(this, GEOMETRY_COLLECTION_SYMBOL); } @@ -371,10 +496,9 @@ export class GeometryCollection extends Geometry { is(geometry: Geometry): geometry is GeometryCollection { if (!(geometry instanceof GeometryCollection)) return false; - const gc = geometry as unknown as GeometryCollection; - if (this.collection.length !== gc.collection.length) 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(gc.collection[i])) return false; + if (!this.collection[i].is(geometry.collection[i])) return false; } return true; @@ -387,9 +511,16 @@ export class GeometryCollection extends Geometry { } } +// Utility functions + +function f(num: number | Decimal) { + if (num instanceof Decimal) return num.toFloat(); + return num; +} + // Geo Json Types -type GeoJson = +export type GeoJson = | GeoJsonPoint | GeoJsonLineString | GeoJsonPolygon diff --git a/packages/sdk/src/value/index.ts b/packages/sqon/src/value/index.ts similarity index 100% rename from packages/sdk/src/value/index.ts rename to packages/sqon/src/value/index.ts diff --git a/packages/sqon/src/value/range.ts b/packages/sqon/src/value/range.ts new file mode 100644 index 00000000..edc7425e --- /dev/null +++ b/packages/sqon/src/value/range.ts @@ -0,0 +1,67 @@ +import { JsonCodec } from "../codec/json/codec.ts"; +import { getRangeJoin } from "../internal/range.ts"; +import { equals } from "../utils/equals.ts"; +import { escapeRangeBound } from "../utils/escape.ts"; +import type { Bound } from "../utils/range.ts"; +import { hasSymbol, markSymbol, RANGE_SYMBOL } from "../utils/symbols.ts"; +import { Value } from "./value.ts"; + +/** + * A SurrealQL range value. + */ +export class Range extends Value { + static override [Symbol.hasInstance](instance: unknown): boolean { + return hasSymbol(instance, RANGE_SYMBOL); + } + + readonly #beg: Bound; + readonly #end: Bound; + + constructor(beg: Bound, end: Bound) { + super(); + this.#beg = beg; + this.#end = end; + markSymbol(this, RANGE_SYMBOL); + } + + equals(other: unknown): boolean { + if (!(other instanceof Range)) return false; + if (this.#beg?.constructor !== other.#beg?.constructor) return false; + if (this.#end?.constructor !== other.#end?.constructor) return false; + + return ( + equals(this.#beg?.value, other.#beg?.value) && + equals(this.#end?.value, other.#end?.value) + ); + } + + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } + return this.toString(); + } + + /** + * @returns The escaped range string + */ + toString(): string { + const beg = escapeRangeBound(this.#beg); + const end = escapeRangeBound(this.#end); + return `${beg}${getRangeJoin(this.#beg, this.#end)}${end}`; + } + + /** + * The range bound beginning + */ + get begin(): Bound { + return this.#beg; + } + + /** + * The range bound ending + */ + get end(): Bound { + return this.#end; + } +} diff --git a/packages/sdk/src/value/record-id-range.ts b/packages/sqon/src/value/record-id-range.ts similarity index 51% rename from packages/sdk/src/value/record-id-range.ts rename to packages/sqon/src/value/record-id-range.ts index 31a41a6b..c082b9a3 100644 --- a/packages/sdk/src/value/record-id-range.ts +++ b/packages/sqon/src/value/record-id-range.ts @@ -1,14 +1,15 @@ -import { InvalidRecordIdError } from "../errors"; -import { getRangeJoin } from "../internal/range"; -import { isValidIdBound, isValidTable } from "../internal/validation"; -import type { WidenRecordIdValue } from "../types/internal"; -import { equals } from "../utils/equals"; -import { escapeIdent, escapeRangeBound } from "../utils/escape"; -import type { Bound } from "../utils/range"; -import { hasSymbol, markSymbol, RECORD_ID_RANGE_SYMBOL } from "../utils/symbols"; -import type { RecordIdValue } from "./record-id"; -import { Table } from "./table"; -import { Value } from "./value"; +import { JsonCodec } from "../codec/json/codec.ts"; +import { InvalidRecordIdError } from "../errors.ts"; +import { getRangeJoin } from "../internal/range.ts"; +import { isValidIdBound, isValidTable } from "../internal/validation.ts"; +import type { WidenRecordIdValue } from "../types/internal.ts"; +import { equals } from "../utils/equals.ts"; +import { escapeIdent, escapeRangeBound } from "../utils/escape.ts"; +import type { Bound } from "../utils/range.ts"; +import { hasSymbol, markSymbol, RECORD_ID_RANGE_SYMBOL } from "../utils/symbols.ts"; +import type { RecordIdValue } from "./record-id.ts"; +import { Table } from "./table.ts"; +import { Value } from "./value.ts"; /** * A SurrealQL record ID range value. @@ -23,9 +24,9 @@ class RecordIdRange< return hasSymbol(instance, RECORD_ID_RANGE_SYMBOL); } - readonly table: Table; - readonly begin: Bound; - readonly end: Bound; + readonly #table: Table; + readonly #beg: Bound; + readonly #end: Bound; constructor(table: Tb | Table, beg: Bound, end: Bound) { super(); @@ -34,27 +35,28 @@ class RecordIdRange< if (!isValidIdBound(beg)) throw new InvalidRecordIdError("Begin bound is not valid"); if (!isValidIdBound(end)) throw new InvalidRecordIdError("End bound is not valid"); - this.table = - table instanceof Table ? (table as unknown as Table) : new Table(table as Tb); - this.begin = beg; - this.end = end; + this.#table = table instanceof Table ? table : new Table(table); + this.#beg = beg; + this.#end = end; markSymbol(this, RECORD_ID_RANGE_SYMBOL); } equals(other: unknown): boolean { if (!(other instanceof RecordIdRange)) return false; - const o = other as unknown as RecordIdRange; - if (this.begin?.constructor !== o.begin?.constructor) return false; - if (this.end?.constructor !== o.end?.constructor) return false; + if (this.#beg?.constructor !== other.#beg?.constructor) return false; + if (this.#end?.constructor !== other.#end?.constructor) return false; return ( - this.table.equals(o.table) && - equals(this.begin?.value, o.begin?.value) && - equals(this.end?.value, o.end?.value) + this.#table.equals(other.#table) && + equals(this.#beg?.value, other.#beg?.value) && + equals(this.#end?.value, other.#end?.value) ); } - toJSON(): string { + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } return this.toString(); } @@ -62,10 +64,31 @@ class RecordIdRange< * @returns The escaped record ID range string */ toString(): string { - const tb = escapeIdent(this.table.name); - const beg = escapeRangeBound(this.begin); - const end = escapeRangeBound(this.end); - return `${tb}:${beg}${getRangeJoin(this.begin, this.end)}${end}`; + const tb = escapeIdent(this.#table.name); + const beg = escapeRangeBound(this.#beg); + const end = escapeRangeBound(this.#end); + return `${tb}:${beg}${getRangeJoin(this.#beg, this.#end)}${end}`; + } + + /** + * The table part value + */ + get table(): Table { + return this.#table; + } + + /** + * The range bound beginning + */ + get begin(): Bound { + return this.#beg; + } + + /** + * The range bound ending + */ + get end(): Bound { + return this.#end; } } @@ -92,6 +115,6 @@ type _RecordIdRange< Tb extends string = string, Id extends RecordIdValue = RecordIdValue, > = RecordIdRange; -const _RecordIdRange = RecordIdRange as RecordIdRangeConstructor; +const _RecordIdRange = RecordIdRange as unknown as RecordIdRangeConstructor; export { _RecordIdRange as RecordIdRange }; diff --git a/packages/sdk/src/value/record-id.ts b/packages/sqon/src/value/record-id.ts similarity index 61% rename from packages/sdk/src/value/record-id.ts rename to packages/sqon/src/value/record-id.ts index 84341164..696c53b4 100644 --- a/packages/sdk/src/value/record-id.ts +++ b/packages/sqon/src/value/record-id.ts @@ -1,12 +1,13 @@ -import { InvalidRecordIdError } from "../errors"; -import { isValidIdPart, isValidTable } from "../internal/validation"; -import type { WidenRecordIdValue } from "../types/internal"; -import { equals } from "../utils/equals"; -import { escapeIdent, escapeIdPart } from "../utils/escape"; -import { hasSymbol, markSymbol, RECORD_ID_SYMBOL } from "../utils/symbols"; -import { Table } from "./table"; -import type { Uuid } from "./uuid"; -import { Value } from "./value"; +import { JsonCodec } from "../codec/json/codec.ts"; +import { InvalidRecordIdError } from "../errors.ts"; +import { isValidIdPart, isValidTable } from "../internal/validation.ts"; +import type { WidenRecordIdValue } from "../types/internal.ts"; +import { equals } from "../utils/equals.ts"; +import { escapeIdent, escapeIdPart } from "../utils/escape.ts"; +import { hasSymbol, markSymbol, RECORD_ID_SYMBOL } from "../utils/symbols.ts"; +import { Table } from "./table.ts"; +import type { Uuid } from "./uuid.ts"; +import { Value } from "./value.ts"; export type RecordIdValue = string | number | Uuid | bigint | unknown[] | Record; @@ -20,8 +21,8 @@ class RecordId; - readonly id: Id; + readonly #table: Table; + readonly #id: Id; constructor(table: Tb | Table, id: Id) { super(); @@ -29,19 +30,20 @@ class RecordId) : new Table(table as Tb); - this.id = id; + this.#table = table instanceof Table ? table : new Table(table); + this.#id = id; markSymbol(this, RECORD_ID_SYMBOL); } equals(other: unknown): boolean { if (!(other instanceof RecordId)) return false; - const o = other as unknown as RecordId; - return this.table.equals(o.table) && equals(this.id, o.id); + return this.#table.equals(other.#table) && equals(this.#id, other.#id); } - toJSON(): string { + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } return this.toString(); } @@ -49,10 +51,24 @@ class RecordId { + return this.#table; + } + + /** + * The ID part value + */ + get id(): Id { + return this.#id; + } } interface RecordIdConstructor { diff --git a/packages/sdk/src/value/string-record-id.ts b/packages/sqon/src/value/string-record-id.ts similarity index 53% rename from packages/sdk/src/value/string-record-id.ts rename to packages/sqon/src/value/string-record-id.ts index 9ea89814..8b8995b0 100644 --- a/packages/sdk/src/value/string-record-id.ts +++ b/packages/sqon/src/value/string-record-id.ts @@ -1,7 +1,8 @@ -import { InvalidTableError } from "../errors"; -import { hasSymbol, markSymbol, STRING_RECORD_ID_SYMBOL } from "../utils/symbols"; -import { RecordId } from "./record-id"; -import { Value } from "./value"; +import { JsonCodec } from "../codec/json/codec.ts"; +import { InvalidRecordIdError } from "../errors.ts"; +import { hasSymbol, markSymbol, STRING_RECORD_ID_SYMBOL } from "../utils/symbols.ts"; +import { RecordId } from "./record-id.ts"; +import { Value } from "./value.ts"; /** * A SurrealQL string-represented record ID value. @@ -11,38 +12,39 @@ export class StringRecordId extends Value { return hasSymbol(instance, STRING_RECORD_ID_SYMBOL); } - readonly rid: string; + readonly #rid: string; constructor(rid: string | StringRecordId | RecordId) { super(); - // In some cases the same method may be used with different data sources - // this can cause this method to be called with an already instanced class object. if (rid instanceof StringRecordId) { - this.rid = (rid as unknown as StringRecordId).rid; + this.#rid = rid.#rid; } else if (rid instanceof RecordId) { - this.rid = (rid as unknown as RecordId).toString(); + this.#rid = rid.toString(); } else if (typeof rid === "string") { - this.rid = rid; + this.#rid = rid; } else { - throw new InvalidTableError("String Record ID must be a string"); + throw new InvalidRecordIdError("String Record ID must be a string"); } markSymbol(this, STRING_RECORD_ID_SYMBOL); } equals(other: unknown): boolean { if (!(other instanceof StringRecordId)) return false; - return this.rid === (other as unknown as StringRecordId).rid; + return this.#rid === other.#rid; } - toJSON(): string { - return this.rid; + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } + return this.#rid; } /** * @returns The string representation of the record ID */ toString(): string { - return this.rid; + return this.#rid; } } diff --git a/packages/sdk/src/value/table.ts b/packages/sqon/src/value/table.ts similarity index 55% rename from packages/sdk/src/value/table.ts rename to packages/sqon/src/value/table.ts index 355061f2..ce1d99b3 100644 --- a/packages/sdk/src/value/table.ts +++ b/packages/sqon/src/value/table.ts @@ -1,7 +1,8 @@ -import { InvalidTableError } from "../errors"; -import { escapeIdent } from "../utils"; -import { hasSymbol, markSymbol, TABLE_SYMBOL } from "../utils/symbols"; -import { Value } from "./value"; +import { JsonCodec } from "../codec/json/codec.ts"; +import { InvalidTableError } from "../errors.ts"; +import { escapeIdent } from "../utils/escape.ts"; +import { hasSymbol, markSymbol, TABLE_SYMBOL } from "../utils/symbols.ts"; +import { Value } from "./value.ts"; /** * A SurrealQL table value. @@ -11,21 +12,24 @@ export class Table extends Value { return hasSymbol(instance, TABLE_SYMBOL); } - readonly name: Tb; + readonly #name: Tb; constructor(tb: Tb) { super(); if (typeof tb !== "string") throw new InvalidTableError("Table must be a string"); - this.name = tb; + this.#name = tb; markSymbol(this, TABLE_SYMBOL); } equals(other: unknown): boolean { if (!(other instanceof Table)) return false; - return this.name === (other as unknown as Table).name; + return this.#name === other.#name; } - toJSON(): string { + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } return this.toString(); } @@ -33,6 +37,13 @@ export class Table extends Value { * @returns The escaped table name */ toString(): string { - return escapeIdent(this.name); + return escapeIdent(this.#name); + } + + /** + * The unescaped table name + */ + get name(): Tb { + return this.#name; } } diff --git a/packages/sdk/src/value/uuid.ts b/packages/sqon/src/value/uuid.ts similarity index 64% rename from packages/sdk/src/value/uuid.ts rename to packages/sqon/src/value/uuid.ts index a9710cf2..bcd4d58a 100644 --- a/packages/sdk/src/value/uuid.ts +++ b/packages/sqon/src/value/uuid.ts @@ -1,6 +1,7 @@ import { UUID, uuidv4obj, uuidv7obj } from "uuidv7"; -import { hasSymbol, markSymbol, UUID_SYMBOL } from "../utils/symbols"; -import { Value } from "./value"; +import { JsonCodec } from "../codec/json/codec.ts"; +import { hasSymbol, markSymbol, UUID_SYMBOL } from "../utils/symbols.ts"; +import { Value } from "./value.ts"; /** * A SurrealQL UUID value. @@ -10,7 +11,7 @@ export class Uuid extends Value { return hasSymbol(instance, UUID_SYMBOL); } - readonly inner: UUID; + readonly #inner: UUID; /** * Constructs a new Uuid by cloning an existing uuid @@ -31,54 +32,57 @@ export class Uuid extends Value { * * @param uuid ArrayBuffer or Uint8Array input */ - constructor(uuid: ArrayBuffer | Uint8Array); + constructor(uuid: ArrayBufferLike | Uint8Array); // Shadow implementation - constructor(uuid: Uuid | UUID | string | ArrayBuffer | Uint8Array) { + constructor(uuid: Uuid | UUID | string | ArrayBufferLike | Uint8Array) { super(); - if (uuid instanceof ArrayBuffer) { - this.inner = UUID.ofInner(new Uint8Array(uuid)); + if (uuid instanceof ArrayBuffer || uuid instanceof SharedArrayBuffer) { + this.#inner = UUID.ofInner(new Uint8Array(uuid)); } else if (uuid instanceof Uint8Array) { - this.inner = UUID.ofInner(uuid); + this.#inner = UUID.ofInner(uuid); } else if (uuid instanceof Uuid) { - this.inner = (uuid as unknown as Uuid).inner; + this.#inner = uuid.#inner; } else if (uuid instanceof UUID) { - this.inner = uuid; + this.#inner = uuid; } else { - this.inner = UUID.parse(uuid as string); + this.#inner = UUID.parse(uuid); } markSymbol(this, UUID_SYMBOL); } equals(other: unknown): boolean { if (!(other instanceof Uuid)) return false; - return this.inner.equals((other as unknown as Uuid).inner); + return this.#inner.equals(other.#inner); } - toJSON(): string { - return this.inner.toString(); + toJSON(): unknown { + if (Value._useExperimentalToJson) { + return JsonCodec.DEFAULT.encode(this); + } + return this.#inner.toString(); } /** * @returns The string representation of the UUID */ toString(): string { - return this.inner.toString(); + return this.#inner.toString(); } /** * Converts the UUID to a Uint8Array */ toUint8Array(): Uint8Array { - return this.inner.bytes; + return this.#inner.bytes; } /** * Converts the UUID to a ArrayBuffer */ toBuffer(): ArrayBufferLike { - return this.inner.bytes.buffer; + return this.#inner.bytes.buffer; } /** diff --git a/packages/sdk/src/value/value.ts b/packages/sqon/src/value/value.ts similarity index 61% rename from packages/sdk/src/value/value.ts rename to packages/sqon/src/value/value.ts index 00d3032e..e3187094 100644 --- a/packages/sdk/src/value/value.ts +++ b/packages/sqon/src/value/value.ts @@ -1,9 +1,11 @@ -import { hasSymbol, markSymbol, VALUE_SYMBOL } from "../utils/symbols"; +import { hasSymbol, markSymbol, VALUE_SYMBOL } from "../utils/symbols.ts"; /** * A complex SurrealQL value type */ export abstract class Value { + protected static _useExperimentalToJson = false; + static [Symbol.hasInstance](instance: unknown): boolean { return hasSymbol(instance, VALUE_SYMBOL); } @@ -26,4 +28,13 @@ export abstract class Value { * Convert this value to a string representation */ abstract toString(): string; + + /** + * Enable the new experimental toJSON implementation. + * + * When enabled, the `toJSON` method will return values encoded by the `JsonCodec` resulting in a type-safe JSON representation. + */ + static useExperimentalToJson(enabled?: boolean) { + Value._useExperimentalToJson = enabled ?? true; + } } diff --git a/packages/sqon/tsconfig.json b/packages/sqon/tsconfig.json new file mode 100644 index 00000000..34a3ceed --- /dev/null +++ b/packages/sqon/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false + } +} diff --git a/packages/tests/integration/__snapshots__/diagnostics.test.ts.snap b/packages/tests/integration/__snapshots__/diagnostics.test.ts.snap index ac762b16..4f2c9552 100644 --- a/packages/tests/integration/__snapshots__/diagnostics.test.ts.snap +++ b/packages/tests/integration/__snapshots__/diagnostics.test.ts.snap @@ -1,6 +1,6 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`diagnostics diagnostic events: events-ws 1`] = ` +exports[`diagnostics diagnostic events: events-http 1`] = ` [ { "phase": "before", @@ -66,14 +66,8 @@ exports[`diagnostics diagnostic events: events-ws 1`] = ` { "firstname": "John", "id": RecordId { - "id": 1, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "lastname": "Doe", }, @@ -88,14 +82,8 @@ exports[`diagnostics diagnostic events: events-ws 1`] = ` }, "params": { "bind__1": RecordId { - "id": 1, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "bind__2": { "firstname": "John", @@ -113,14 +101,8 @@ exports[`diagnostics diagnostic events: events-ws 1`] = ` "result": { "params": { "bind__1": RecordId { - "id": 1, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "bind__2": { "firstname": "John", @@ -137,19 +119,13 @@ exports[`diagnostics diagnostic events: events-ws 1`] = ` ] `; -exports[`diagnostics extract query: query-ws 1`] = `"CREATE ONLY $bind__1 CONTENT $bind__2"`; +exports[`diagnostics extract query: query-http 1`] = `"CREATE ONLY $bind__1 CONTENT $bind__2"`; -exports[`diagnostics extract query: params-ws 1`] = ` +exports[`diagnostics extract query: params-http 1`] = ` { "bind__1": RecordId { - "id": "test", [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "test", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "bind__2": { "firstname": "John", @@ -158,7 +134,7 @@ exports[`diagnostics extract query: params-ws 1`] = ` } `; -exports[`diagnostics diagnostic events: events-http 1`] = ` +exports[`diagnostics diagnostic events: events-ws 1`] = ` [ { "phase": "before", @@ -224,14 +200,8 @@ exports[`diagnostics diagnostic events: events-http 1`] = ` { "firstname": "John", "id": RecordId { - "id": 1, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "lastname": "Doe", }, @@ -246,14 +216,8 @@ exports[`diagnostics diagnostic events: events-http 1`] = ` }, "params": { "bind__1": RecordId { - "id": 1, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "bind__2": { "firstname": "John", @@ -271,14 +235,8 @@ exports[`diagnostics diagnostic events: events-http 1`] = ` "result": { "params": { "bind__1": RecordId { - "id": 1, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "bind__2": { "firstname": "John", @@ -295,19 +253,13 @@ exports[`diagnostics diagnostic events: events-http 1`] = ` ] `; -exports[`diagnostics extract query: query-http 1`] = `"CREATE ONLY $bind__1 CONTENT $bind__2"`; +exports[`diagnostics extract query: query-ws 1`] = `"CREATE ONLY $bind__1 CONTENT $bind__2"`; -exports[`diagnostics extract query: params-http 1`] = ` +exports[`diagnostics extract query: params-ws 1`] = ` { "bind__1": RecordId { - "id": "test", [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "test", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "bind__2": { "firstname": "John", diff --git a/packages/tests/integration/__snapshots__/import.test.ts.snap b/packages/tests/integration/__snapshots__/import.test.ts.snap index bba2a7ca..d7e13282 100644 --- a/packages/tests/integration/__snapshots__/import.test.ts.snap +++ b/packages/tests/integration/__snapshots__/import.test.ts.snap @@ -5,14 +5,8 @@ exports[`import basic 1`] = ` { "hello": "world", "id": RecordId { - "id": 1, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "foo", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, }, ] diff --git a/packages/tests/integration/integrity.test.ts b/packages/tests/integration/integrity.test.ts index 1435d2ac..fc4b5f69 100644 --- a/packages/tests/integration/integrity.test.ts +++ b/packages/tests/integration/integrity.test.ts @@ -14,7 +14,10 @@ describe("data integrity", async () => { }); }; - expect(execute()).rejects.toThrow(InvalidDateError); + expect(execute()).rejects.toMatchObject({ + name: "SurrealSqonError", + inner: expect.any(InvalidDateError), + }); }); test("NaN number handling", async () => { diff --git a/packages/tests/integration/query/__snapshots__/api.test.ts.snap b/packages/tests/integration/query/__snapshots__/api.test.ts.snap index 2d5fa084..2eafa1d6 100644 --- a/packages/tests/integration/query/__snapshots__/api.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/api.test.ts.snap @@ -1,11 +1,11 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`api compile: query-ws 1`] = `"api::invoke($bind__4, $bind__5)"`; +exports[`api compile: query-http 1`] = `"api::invoke($bind__3, $bind__4)"`; -exports[`api compile: bindings-ws 1`] = ` +exports[`api compile: bindings-http 1`] = ` { - "bind__4": "/identity", - "bind__5": { + "bind__3": "/identity", + "bind__4": { "body": undefined, "headers": {}, "method": "get", @@ -14,12 +14,12 @@ exports[`api compile: bindings-ws 1`] = ` } `; -exports[`api compile: query-http 1`] = `"api::invoke($bind__3, $bind__4)"`; +exports[`api compile: query-ws 1`] = `"api::invoke($bind__4, $bind__5)"`; -exports[`api compile: bindings-http 1`] = ` +exports[`api compile: bindings-ws 1`] = ` { - "bind__3": "/identity", - "bind__4": { + "bind__4": "/identity", + "bind__5": { "body": undefined, "headers": {}, "method": "get", diff --git a/packages/tests/integration/query/__snapshots__/create.test.ts.snap b/packages/tests/integration/query/__snapshots__/create.test.ts.snap index 28fbfa57..ae4aafce 100644 --- a/packages/tests/integration/query/__snapshots__/create.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/create.test.ts.snap @@ -1,63 +1,45 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`create() compile: query-ws 1`] = `"CREATE $bind__4 CONTENT $bind__5 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__6"`; +exports[`create() compile: query-http 1`] = `"CREATE $bind__3 CONTENT $bind__4 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__5"`; -exports[`create() compile: bindings-ws 1`] = ` +exports[`create() compile: bindings-http 1`] = ` { - "bind__4": Table { - "name": "person", + "bind__3": Table { [Symbol(surrealdb.Table)]: true, [Symbol(surrealdb.Value)]: true, }, - "bind__5": { + "bind__4": { "firstname": "Mary", "id": RecordId { - "id": 2, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "lastname": "Doe", }, - "bind__6": DateTime { - "_ns": 0n, - "_seconds": 0n, + "bind__5": DateTime { [Symbol(surrealdb.DateTime)]: true, [Symbol(surrealdb.Value)]: true, }, } `; -exports[`create() compile: query-http 1`] = `"CREATE $bind__3 CONTENT $bind__4 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__5"`; +exports[`create() compile: query-ws 1`] = `"CREATE $bind__4 CONTENT $bind__5 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__6"`; -exports[`create() compile: bindings-http 1`] = ` +exports[`create() compile: bindings-ws 1`] = ` { - "bind__3": Table { - "name": "person", + "bind__4": Table { [Symbol(surrealdb.Table)]: true, [Symbol(surrealdb.Value)]: true, }, - "bind__4": { + "bind__5": { "firstname": "Mary", "id": RecordId { - "id": 2, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "lastname": "Doe", }, - "bind__5": DateTime { - "_ns": 0n, - "_seconds": 0n, + "bind__6": DateTime { [Symbol(surrealdb.DateTime)]: true, [Symbol(surrealdb.Value)]: true, }, diff --git a/packages/tests/integration/query/__snapshots__/delete.test.ts.snap b/packages/tests/integration/query/__snapshots__/delete.test.ts.snap index 1e29ad2d..4d71cd36 100644 --- a/packages/tests/integration/query/__snapshots__/delete.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/delete.test.ts.snap @@ -1,35 +1,29 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`delete() compile: query-ws 1`] = `"DELETE $bind__4 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__5"`; +exports[`delete() compile: query-http 1`] = `"DELETE $bind__3 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__4"`; -exports[`delete() compile: bindings-ws 1`] = ` +exports[`delete() compile: bindings-http 1`] = ` { - "bind__4": Table { - "name": "person", + "bind__3": Table { [Symbol(surrealdb.Table)]: true, [Symbol(surrealdb.Value)]: true, }, - "bind__5": DateTime { - "_ns": 0n, - "_seconds": 0n, + "bind__4": DateTime { [Symbol(surrealdb.DateTime)]: true, [Symbol(surrealdb.Value)]: true, }, } `; -exports[`delete() compile: query-http 1`] = `"DELETE $bind__3 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__4"`; +exports[`delete() compile: query-ws 1`] = `"DELETE $bind__4 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__5"`; -exports[`delete() compile: bindings-http 1`] = ` +exports[`delete() compile: bindings-ws 1`] = ` { - "bind__3": Table { - "name": "person", + "bind__4": Table { [Symbol(surrealdb.Table)]: true, [Symbol(surrealdb.Value)]: true, }, - "bind__4": DateTime { - "_ns": 0n, - "_seconds": 0n, + "bind__5": DateTime { [Symbol(surrealdb.DateTime)]: true, [Symbol(surrealdb.Value)]: true, }, diff --git a/packages/tests/integration/query/__snapshots__/insert.test.ts.snap b/packages/tests/integration/query/__snapshots__/insert.test.ts.snap index 05074578..bfdabbb1 100644 --- a/packages/tests/integration/query/__snapshots__/insert.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/insert.test.ts.snap @@ -1,57 +1,41 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`insert() compile: query-ws 1`] = `"INSERT RELATION IGNORE $bind__4 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__5"`; +exports[`insert() compile: query-http 1`] = `"INSERT RELATION IGNORE $bind__3 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__4"`; -exports[`insert() compile: bindings-ws 1`] = ` +exports[`insert() compile: bindings-http 1`] = ` { - "bind__4": [ + "bind__3": [ { "firstname": "John", "id": RecordId { - "id": 3, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "lastname": "Doe", }, ], - "bind__5": DateTime { - "_ns": 0n, - "_seconds": 0n, + "bind__4": DateTime { [Symbol(surrealdb.DateTime)]: true, [Symbol(surrealdb.Value)]: true, }, } `; -exports[`insert() compile: query-http 1`] = `"INSERT RELATION IGNORE $bind__3 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__4"`; +exports[`insert() compile: query-ws 1`] = `"INSERT RELATION IGNORE $bind__4 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__5"`; -exports[`insert() compile: bindings-http 1`] = ` +exports[`insert() compile: bindings-ws 1`] = ` { - "bind__3": [ + "bind__4": [ { "firstname": "John", "id": RecordId { - "id": 3, [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, "lastname": "Doe", }, ], - "bind__4": DateTime { - "_ns": 0n, - "_seconds": 0n, + "bind__5": DateTime { [Symbol(surrealdb.DateTime)]: true, [Symbol(surrealdb.Value)]: true, }, diff --git a/packages/tests/integration/query/__snapshots__/live.test.ts.snap b/packages/tests/integration/query/__snapshots__/live.test.ts.snap index 6079a310..a4fa4a8f 100644 --- a/packages/tests/integration/query/__snapshots__/live.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/live.test.ts.snap @@ -10,7 +10,6 @@ exports[`live() / liveOf() compile: bindings-ws 1`] = ` "lastname", ], "bind__5": Table { - "name": "person", [Symbol(surrealdb.Table)]: true, [Symbol(surrealdb.Value)]: true, }, diff --git a/packages/tests/integration/query/__snapshots__/query.test.ts.snap b/packages/tests/integration/query/__snapshots__/query.test.ts.snap index 063afa7e..c1e263a8 100644 --- a/packages/tests/integration/query/__snapshots__/query.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/query.test.ts.snap @@ -1,35 +1,23 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`query() inner query: query-ws 1`] = `"RETURN $bind__4"`; +exports[`query() inner query: query-http 1`] = `"RETURN $bind__3"`; -exports[`query() inner query: bindings-ws 1`] = ` +exports[`query() inner query: bindings-http 1`] = ` { - "bind__4": RecordId { - "id": "world", + "bind__3": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "hello", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, } `; -exports[`query() inner query: query-http 1`] = `"RETURN $bind__3"`; +exports[`query() inner query: query-ws 1`] = `"RETURN $bind__4"`; -exports[`query() inner query: bindings-http 1`] = ` +exports[`query() inner query: bindings-ws 1`] = ` { - "bind__3": RecordId { - "id": "world", + "bind__4": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "hello", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, } `; diff --git a/packages/tests/integration/query/__snapshots__/relate.test.ts.snap b/packages/tests/integration/query/__snapshots__/relate.test.ts.snap index e0933155..1704db59 100644 --- a/packages/tests/integration/query/__snapshots__/relate.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/relate.test.ts.snap @@ -1,75 +1,45 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`relate() compile: query-ws 1`] = `"RELATE ONLY $bind__4->$bind__5->$bind__6 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__7"`; +exports[`relate() compile: query-http 1`] = `"RELATE ONLY $bind__3->$bind__4->$bind__5 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__6"`; -exports[`relate() compile: bindings-ws 1`] = ` +exports[`relate() compile: bindings-http 1`] = ` { - "bind__4": RecordId { - "id": "in", + "bind__3": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "edge", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, - "bind__5": Table { - "name": "graph", + "bind__4": Table { [Symbol(surrealdb.Table)]: true, [Symbol(surrealdb.Value)]: true, }, - "bind__6": RecordId { - "id": "out", + "bind__5": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "edge", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, - "bind__7": DateTime { - "_ns": 0n, - "_seconds": 0n, + "bind__6": DateTime { [Symbol(surrealdb.DateTime)]: true, [Symbol(surrealdb.Value)]: true, }, } `; -exports[`relate() compile: query-http 1`] = `"RELATE ONLY $bind__3->$bind__4->$bind__5 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__6"`; +exports[`relate() compile: query-ws 1`] = `"RELATE ONLY $bind__4->$bind__5->$bind__6 RETURN DIFF TIMEOUT TIMEOUT 1s VERSION $bind__7"`; -exports[`relate() compile: bindings-http 1`] = ` +exports[`relate() compile: bindings-ws 1`] = ` { - "bind__3": RecordId { - "id": "in", + "bind__4": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "edge", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, - "bind__4": Table { - "name": "graph", + "bind__5": Table { [Symbol(surrealdb.Table)]: true, [Symbol(surrealdb.Value)]: true, }, - "bind__5": RecordId { - "id": "out", + "bind__6": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "edge", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, - "bind__6": DateTime { - "_ns": 0n, - "_seconds": 0n, + "bind__7": DateTime { [Symbol(surrealdb.DateTime)]: true, [Symbol(surrealdb.Value)]: true, }, diff --git a/packages/tests/integration/query/__snapshots__/run.test.ts.snap b/packages/tests/integration/query/__snapshots__/run.test.ts.snap index c94cb585..ba4f3a93 100644 --- a/packages/tests/integration/query/__snapshots__/run.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/run.test.ts.snap @@ -1,25 +1,25 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`run() compile: query-ws 1`] = `"array::add($bind__4, $bind__5, )"`; +exports[`run() compile: query-http 1`] = `"array::add($bind__3, $bind__4, )"`; -exports[`run() compile: bindings-ws 1`] = ` +exports[`run() compile: bindings-http 1`] = ` { - "bind__4": [ + "bind__3": [ 1, 2, ], - "bind__5": 3, + "bind__4": 3, } `; -exports[`run() compile: query-http 1`] = `"array::add($bind__3, $bind__4, )"`; +exports[`run() compile: query-ws 1`] = `"array::add($bind__4, $bind__5, )"`; -exports[`run() compile: bindings-http 1`] = ` +exports[`run() compile: bindings-ws 1`] = ` { - "bind__3": [ + "bind__4": [ 1, 2, ], - "bind__4": 3, + "bind__5": 3, } `; diff --git a/packages/tests/integration/query/__snapshots__/select.test.ts.snap b/packages/tests/integration/query/__snapshots__/select.test.ts.snap index a85a7766..a7bdfe95 100644 --- a/packages/tests/integration/query/__snapshots__/select.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/select.test.ts.snap @@ -1,69 +1,53 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`select() compile: query-ws 1`] = `"SELECT type::fields($bind__4) FROM ONLY $bind__5 WHERE age = $bind__6 START $bind__7 LIMIT $bind__8 FETCH type::fields($bind__9) TIMEOUT TIMEOUT 1s VERSION $bind__10"`; +exports[`select() compile: query-http 1`] = `"SELECT type::fields($bind__3) FROM ONLY $bind__4 WHERE age = $bind__5 START $bind__6 LIMIT $bind__7 FETCH type::fields($bind__8) TIMEOUT TIMEOUT 1s VERSION $bind__9"`; -exports[`select() compile: bindings-ws 1`] = ` +exports[`select() compile: bindings-http 1`] = ` { - "bind__10": DateTime { - "_ns": 0n, - "_seconds": 0n, - [Symbol(surrealdb.DateTime)]: true, - [Symbol(surrealdb.Value)]: true, - }, - "bind__4": [ + "bind__3": [ "age", "test", "lastname", ], - "bind__5": RecordId { - "id": 1, + "bind__4": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, - "bind__6": 30, + "bind__5": 30, + "bind__6": 1, "bind__7": 1, - "bind__8": 1, - "bind__9": [ + "bind__8": [ "foo", ], + "bind__9": DateTime { + [Symbol(surrealdb.DateTime)]: true, + [Symbol(surrealdb.Value)]: true, + }, } `; -exports[`select() compile: query-http 1`] = `"SELECT type::fields($bind__3) FROM ONLY $bind__4 WHERE age = $bind__5 START $bind__6 LIMIT $bind__7 FETCH type::fields($bind__8) TIMEOUT TIMEOUT 1s VERSION $bind__9"`; +exports[`select() compile: query-ws 1`] = `"SELECT type::fields($bind__4) FROM ONLY $bind__5 WHERE age = $bind__6 START $bind__7 LIMIT $bind__8 FETCH type::fields($bind__9) TIMEOUT TIMEOUT 1s VERSION $bind__10"`; -exports[`select() compile: bindings-http 1`] = ` +exports[`select() compile: bindings-ws 1`] = ` { - "bind__3": [ + "bind__10": DateTime { + [Symbol(surrealdb.DateTime)]: true, + [Symbol(surrealdb.Value)]: true, + }, + "bind__4": [ "age", "test", "lastname", ], - "bind__4": RecordId { - "id": 1, + "bind__5": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, - "bind__5": 30, - "bind__6": 1, + "bind__6": 30, "bind__7": 1, - "bind__8": [ + "bind__8": 1, + "bind__9": [ "foo", ], - "bind__9": DateTime { - "_ns": 0n, - "_seconds": 0n, - [Symbol(surrealdb.DateTime)]: true, - [Symbol(surrealdb.Value)]: true, - }, } `; diff --git a/packages/tests/integration/query/__snapshots__/update.test.ts.snap b/packages/tests/integration/query/__snapshots__/update.test.ts.snap index fc9af014..00fa93b3 100644 --- a/packages/tests/integration/query/__snapshots__/update.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/update.test.ts.snap @@ -1,45 +1,33 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`update() compile: query-ws 1`] = `"UPDATE ONLY $bind__4 CONTENT $bind__5 WHERE age = $bind__6 RETURN DIFF TIMEOUT TIMEOUT 1s"`; +exports[`update() compile: query-http 1`] = `"UPDATE ONLY $bind__3 CONTENT $bind__4 WHERE age = $bind__5 RETURN DIFF TIMEOUT TIMEOUT 1s"`; -exports[`update() compile: bindings-ws 1`] = ` +exports[`update() compile: bindings-http 1`] = ` { - "bind__4": RecordId { - "id": 1, + "bind__3": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, - "bind__5": { + "bind__4": { "firstname": "John", "lastname": "Doe", }, - "bind__6": 30, + "bind__5": 30, } `; -exports[`update() compile: query-http 1`] = `"UPDATE ONLY $bind__3 CONTENT $bind__4 WHERE age = $bind__5 RETURN DIFF TIMEOUT TIMEOUT 1s"`; +exports[`update() compile: query-ws 1`] = `"UPDATE ONLY $bind__4 CONTENT $bind__5 WHERE age = $bind__6 RETURN DIFF TIMEOUT TIMEOUT 1s"`; -exports[`update() compile: bindings-http 1`] = ` +exports[`update() compile: bindings-ws 1`] = ` { - "bind__3": RecordId { - "id": 1, + "bind__4": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, - "bind__4": { + "bind__5": { "firstname": "John", "lastname": "Doe", }, - "bind__5": 30, + "bind__6": 30, } `; diff --git a/packages/tests/integration/query/__snapshots__/upsert.test.ts.snap b/packages/tests/integration/query/__snapshots__/upsert.test.ts.snap index 09729c2f..472ac0dd 100644 --- a/packages/tests/integration/query/__snapshots__/upsert.test.ts.snap +++ b/packages/tests/integration/query/__snapshots__/upsert.test.ts.snap @@ -1,45 +1,33 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`upsert() compile: query-ws 1`] = `"UPSERT ONLY $bind__4 CONTENT $bind__5 WHERE age = $bind__6 RETURN DIFF TIMEOUT TIMEOUT 1s"`; +exports[`upsert() compile: query-http 1`] = `"UPSERT ONLY $bind__3 CONTENT $bind__4 WHERE age = $bind__5 RETURN DIFF TIMEOUT TIMEOUT 1s"`; -exports[`upsert() compile: bindings-ws 1`] = ` +exports[`upsert() compile: bindings-http 1`] = ` { - "bind__4": RecordId { - "id": 1, + "bind__3": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, - "bind__5": { + "bind__4": { "firstname": "John", "lastname": "Doe", }, - "bind__6": 30, + "bind__5": 30, } `; -exports[`upsert() compile: query-http 1`] = `"UPSERT ONLY $bind__3 CONTENT $bind__4 WHERE age = $bind__5 RETURN DIFF TIMEOUT TIMEOUT 1s"`; +exports[`upsert() compile: query-ws 1`] = `"UPSERT ONLY $bind__4 CONTENT $bind__5 WHERE age = $bind__6 RETURN DIFF TIMEOUT TIMEOUT 1s"`; -exports[`upsert() compile: bindings-http 1`] = ` +exports[`upsert() compile: bindings-ws 1`] = ` { - "bind__3": RecordId { - "id": 1, + "bind__4": RecordId { [Symbol(surrealdb.RecordId)]: true, [Symbol(surrealdb.Value)]: true, - "table": Table { - "name": "person", - [Symbol(surrealdb.Table)]: true, - [Symbol(surrealdb.Value)]: true, - }, }, - "bind__4": { + "bind__5": { "firstname": "John", "lastname": "Doe", }, - "bind__5": 30, + "bind__6": 30, } `; diff --git a/packages/tests/unit/sqon/codecs/cbor.test.ts b/packages/tests/unit/sqon/codecs/cbor.test.ts new file mode 100644 index 00000000..9259cd5b --- /dev/null +++ b/packages/tests/unit/sqon/codecs/cbor.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, test } from "bun:test"; +import { + BoundExcluded, + BoundIncluded, + CborCodec, + DateTime, + Decimal, + Duration, + FileRef, + Future, + GeometryCollection, + GeometryLine, + GeometryMultiLine, + GeometryMultiPoint, + GeometryMultiPolygon, + GeometryPoint, + GeometryPolygon, + Range, + RecordId, + RecordIdRange, + StringRecordId, + Table, + Uuid, +} from "surrealdb"; + +const codec = new CborCodec({}); + +function roundTrip(value: T): unknown { + return codec.decode(codec.encode(value)); +} + +describe("CborCodec", () => { + describe("primitives", () => { + test("null", () => { + expect(roundTrip(null)).toBeNull(); + }); + + test("boolean", () => { + expect(roundTrip(true)).toBe(true); + expect(roundTrip(false)).toBe(false); + }); + + test("string", () => { + expect(roundTrip("hello")).toBe("hello"); + }); + + test("number", () => { + expect(roundTrip(42)).toBe(42); + expect(roundTrip(3.14)).toBe(3.14); + expect(roundTrip(-100)).toBe(-100); + }); + + test("bigint", () => { + const encoded = codec.encode(123n); + expect(encoded).toBeInstanceOf(Uint8Array); + expect(codec.decode(encoded)).toBe(123); + }); + }); + + describe("none", () => { + test("round-trip", () => { + expect(roundTrip(undefined)).toBeUndefined(); + }); + }); + + describe("DateTime", () => { + test("round-trip", () => { + const dt = new DateTime("2024-06-15T12:30:45Z"); + const result = roundTrip(dt); + expect(result).toBeInstanceOf(DateTime); + expect((result as DateTime).toISOString()).toBe(dt.toISOString()); + }); + + test("native Date round-trip", () => { + const date = new Date("2024-03-01T00:00:00.000Z"); + const result = roundTrip(date); + expect(result).toBeInstanceOf(DateTime); + }); + + test("useNativeDates option", () => { + const nativeCodec = new CborCodec({ useNativeDates: true }); + const dt = new DateTime("2024-01-01T00:00:00Z"); + const encoded = nativeCodec.encode(dt); + const result = nativeCodec.decode(encoded); + expect(result).toBeInstanceOf(Date); + }); + }); + + describe("Decimal", () => { + test("round-trip", () => { + const d = new Decimal("123456789.987654321"); + const result = roundTrip(d); + expect(result).toBeInstanceOf(Decimal); + expect((result as Decimal).toString()).toBe("123456789.987654321"); + }); + }); + + describe("Duration", () => { + test("round-trip", () => { + const d = new Duration("2d12h30m15s"); + const result = roundTrip(d); + expect(result).toBeInstanceOf(Duration); + expect((result as Duration).toString()).toBe(d.toString()); + }); + }); + + describe("Uuid", () => { + test("round-trip", () => { + const uuid = new Uuid("d2f72714-a387-487a-8eae-451330796ff4"); + const result = roundTrip(uuid); + expect(result).toBeInstanceOf(Uuid); + expect((result as Uuid).toString()).toBe("d2f72714-a387-487a-8eae-451330796ff4"); + }); + }); + + describe("RecordId", () => { + test("round-trip with string id", () => { + const rid = new RecordId("users", "bob"); + const result = roundTrip(rid); + expect(result).toBeInstanceOf(RecordId); + const decoded = result as RecordId; + expect(decoded.table.name).toBe("users"); + }); + + test("round-trip with numeric id", () => { + const rid = new RecordId("users", 42); + const result = roundTrip(rid); + expect(result).toBeInstanceOf(RecordId); + const decoded = result as RecordId; + expect(decoded.table.name).toBe("users"); + }); + + test("round-trip with array id", () => { + const rid = new RecordId("matrix", ["a", "b"]); + const result = roundTrip(rid); + expect(result).toBeInstanceOf(RecordId); + }); + + test("round-trip with object id", () => { + const rid = new RecordId("events", { city: "London", year: 2024 }); + const result = roundTrip(rid); + expect(result).toBeInstanceOf(RecordId); + }); + }); + + describe("StringRecordId", () => { + test("round-trip", () => { + const srid = new StringRecordId("users:bob"); + const result = roundTrip(srid); + expect(result).toBeInstanceOf(StringRecordId); + expect((result as StringRecordId).toString()).toBe("users:bob"); + }); + }); + + describe("RecordIdRange", () => { + test("round-trip", () => { + const range = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(100)); + const result = roundTrip(range); + expect(result).toBeInstanceOf(RecordIdRange); + const decoded = result as RecordIdRange; + expect(decoded.table.name).toBe("users"); + expect(decoded.begin).toBeInstanceOf(BoundIncluded); + expect(decoded.begin?.value).toBe(1); + expect(decoded.end).toBeInstanceOf(BoundExcluded); + expect(decoded.end?.value).toBe(100); + }); + }); + + describe("Table", () => { + test("round-trip", () => { + const result = roundTrip(new Table("users")); + expect(result).toBeInstanceOf(Table); + expect((result as Table).name).toBe("users"); + }); + }); + + describe("Range", () => { + test("round-trip with both bounds", () => { + const range = new Range(new BoundIncluded(5), new BoundExcluded(50)); + const result = roundTrip(range); + expect(result).toBeInstanceOf(Range); + const decoded = result as Range; + expect(decoded.begin).toBeInstanceOf(BoundIncluded); + expect(decoded.begin?.value).toBe(5); + expect(decoded.end).toBeInstanceOf(BoundExcluded); + expect(decoded.end?.value).toBe(50); + }); + + test("round-trip with unbounded begin", () => { + const range = new Range(undefined, new BoundIncluded(10)); + const result = roundTrip(range); + expect(result).toBeInstanceOf(Range); + const decoded = result as Range; + expect(decoded.begin).toBeUndefined(); + expect(decoded.end).toBeInstanceOf(BoundIncluded); + expect(decoded.end?.value).toBe(10); + }); + }); + + describe("FileRef", () => { + test("round-trip", () => { + const file = new FileRef("images", "/avatar.png"); + const result = roundTrip(file); + expect(result).toBeInstanceOf(FileRef); + expect((result as FileRef).bucket).toBe("images"); + expect((result as FileRef).key).toBe("/avatar.png"); + }); + }); + + describe("Future", () => { + test("round-trip", () => { + const future = new Future("{ time::now() }"); + const result = roundTrip(future); + expect(result).toBeInstanceOf(Future); + expect((result as Future).body).toBe("{ time::now() }"); + }); + }); + + describe("Uint8Array", () => { + test("round-trip preserves binary data", () => { + const bytes = new Uint8Array([0, 1, 2, 255]); + const result = roundTrip(bytes); + const decoded = + result instanceof Uint8Array ? result : new Uint8Array(result as ArrayBuffer); + expect(decoded).toEqual(bytes); + }); + }); + + describe("Set", () => { + test("round-trip", () => { + const s = new Set([1, 2, 3]); + const result = roundTrip(s); + expect(result).toBeInstanceOf(Set); + expect(result).toEqual(new Set([1, 2, 3])); + }); + + test("round-trip with nested values", () => { + const s = new Set([new Decimal("1.5"), new Decimal("2.5")]); + const result = roundTrip(s) as Set; + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(2); + for (const v of result) { + expect(v).toBeInstanceOf(Decimal); + } + }); + }); + + describe("Geometry", () => { + test("round-trip Point", () => { + const point = new GeometryPoint([10, 20]); + const result = roundTrip(point); + expect(result).toBeInstanceOf(GeometryPoint); + expect((result as GeometryPoint).point).toEqual([10, 20]); + }); + + test("round-trip LineString", () => { + const line = new GeometryLine([new GeometryPoint([0, 0]), new GeometryPoint([1, 1])]); + const result = roundTrip(line); + expect(result).toBeInstanceOf(GeometryLine); + const decoded = result as GeometryLine; + expect(decoded.line).toHaveLength(2); + }); + + test("round-trip Polygon", () => { + const poly = new GeometryPolygon([ + new GeometryLine([ + new GeometryPoint([0, 0]), + new GeometryPoint([1, 0]), + new GeometryPoint([1, 1]), + new GeometryPoint([0, 0]), + ]), + ]); + const result = roundTrip(poly); + expect(result).toBeInstanceOf(GeometryPolygon); + }); + + test("round-trip MultiPoint", () => { + const mp = new GeometryMultiPoint([ + new GeometryPoint([0, 0]), + new GeometryPoint([1, 1]), + ]); + const result = roundTrip(mp); + expect(result).toBeInstanceOf(GeometryMultiPoint); + expect((result as GeometryMultiPoint).points).toHaveLength(2); + }); + + test("round-trip MultiLineString", () => { + const ml = new GeometryMultiLine([ + new GeometryLine([new GeometryPoint([0, 0]), new GeometryPoint([1, 1])]), + ]); + const result = roundTrip(ml); + expect(result).toBeInstanceOf(GeometryMultiLine); + }); + + test("round-trip MultiPolygon", () => { + const mpoly = new GeometryMultiPolygon([ + new GeometryPolygon([ + new GeometryLine([ + new GeometryPoint([0, 0]), + new GeometryPoint([1, 0]), + new GeometryPoint([1, 1]), + new GeometryPoint([0, 0]), + ]), + ]), + ]); + const result = roundTrip(mpoly); + expect(result).toBeInstanceOf(GeometryMultiPolygon); + }); + + test("round-trip GeometryCollection", () => { + const coll = new GeometryCollection([ + new GeometryPoint([1, 2]), + new GeometryLine([new GeometryPoint([0, 0]), new GeometryPoint([3, 3])]), + ]); + const result = roundTrip(coll); + expect(result).toBeInstanceOf(GeometryCollection); + const decoded = result as GeometryCollection; + expect(decoded.collection).toHaveLength(2); + expect(decoded.collection[0]).toBeInstanceOf(GeometryPoint); + expect(decoded.collection[1]).toBeInstanceOf(GeometryLine); + }); + }); + + describe("containers", () => { + test("array round-trip", () => { + const arr = [1, "two", new Decimal("3")]; + const result = roundTrip(arr) as unknown[]; + expect(result[0]).toBe(1); + expect(result[1]).toBe("two"); + expect(result[2]).toBeInstanceOf(Decimal); + }); + + test("object round-trip", () => { + const obj = { name: "Alice", age: 30 }; + expect(roundTrip(obj)).toEqual({ name: "Alice", age: 30 }); + }); + + test("nested values in objects", () => { + const obj = { + id: new RecordId("users", "alice"), + created: new DateTime("2024-01-01T00:00:00Z"), + }; + const result = roundTrip(obj) as Record; + expect(result.id).toBeInstanceOf(RecordId); + expect(result.created).toBeInstanceOf(DateTime); + }); + + test("Map", () => { + const map = new Map([ + ["key1", 1], + ["key2", "value"], + ]); + const result = roundTrip(map); + expect(result).toEqual({ key1: 1, key2: "value" }); + }); + }); + + describe("visitors", () => { + test("valueEncodeVisitor", () => { + const visitor = new CborCodec({ + valueEncodeVisitor: (v) => { + if (typeof v === "string") return v.toUpperCase(); + return v; + }, + }); + + expect(visitor.decode(visitor.encode("hello"))).toBe("HELLO"); + }); + + test("valueDecodeVisitor", () => { + const visitor = new CborCodec({ + valueDecodeVisitor: (v) => { + if (v instanceof Table) return v.name; + return v; + }, + }); + + const encoded = visitor.encode(new Table("users")); + expect(visitor.decode(encoded)).toBe("users"); + }); + }); +}); diff --git a/packages/tests/unit/sqon/codecs/json.test.ts b/packages/tests/unit/sqon/codecs/json.test.ts new file mode 100644 index 00000000..e309fadd --- /dev/null +++ b/packages/tests/unit/sqon/codecs/json.test.ts @@ -0,0 +1,539 @@ +import { describe, expect, test } from "bun:test"; +import { + BoundExcluded, + BoundIncluded, + DateTime, + Decimal, + Duration, + FileRef, + Future, + GeometryCollection, + GeometryLine, + GeometryMultiLine, + GeometryMultiPoint, + GeometryMultiPolygon, + GeometryPoint, + GeometryPolygon, + JsonCodec, + Range, + RecordId, + RecordIdRange, + StringRecordId, + Table, + Uuid, +} from "surrealdb"; + +const codec = new JsonCodec({}); + +function roundTrip(value: T): unknown { + return codec.decode(codec.encode(value)); +} + +describe("JsonCodec", () => { + describe("primitives", () => { + test("null", () => { + expect(codec.encode(null)).toBeNull(); + expect(roundTrip(null)).toBeNull(); + }); + + test("boolean", () => { + expect(codec.encode(true)).toBe(true); + expect(codec.encode(false)).toBe(false); + expect(roundTrip(true)).toBe(true); + expect(roundTrip(false)).toBe(false); + }); + + test("string", () => { + expect(codec.encode("hello")).toBe("hello"); + expect(roundTrip("hello")).toBe("hello"); + }); + + test("number", () => { + expect(codec.encode(42)).toBe(42); + expect(codec.encode(3.14)).toBe(3.14); + expect(roundTrip(42)).toBe(42); + }); + + test("bigint", () => { + expect(codec.encode(123n)).toBe(123n); + expect(roundTrip(123n)).toBe(123n); + }); + }); + + describe("none", () => { + test("encode", () => { + expect(codec.encode(undefined)).toEqual({ $none: true }); + }); + + test("decode", () => { + expect(codec.decode({ $none: true })).toBeUndefined(); + }); + }); + + describe("DateTime", () => { + test("encode", () => { + const dt = new DateTime("2024-01-15T10:30:00Z"); + const encoded = codec.encode(dt); + expect(encoded).toEqual({ $datetime: "2024-01-15T10:30:00.000Z" }); + }); + + test("round-trip", () => { + const dt = new DateTime("2024-06-15T12:00:00Z"); + const result = roundTrip(dt); + expect(result).toBeInstanceOf(DateTime); + expect((result as DateTime).toISOString()).toBe(dt.toISOString()); + }); + + test("native Date encode", () => { + const date = new Date("2024-03-01T00:00:00.000Z"); + const encoded = codec.encode(date); + expect(encoded).toEqual({ $datetime: "2024-03-01T00:00:00.000Z" }); + }); + + test("useNativeDates option", () => { + const nativeCodec = new JsonCodec({ useNativeDates: true }); + const encoded = { $datetime: "2024-01-01T00:00:00Z" }; + const result = nativeCodec.decode(encoded); + expect(result).toBeInstanceOf(Date); + }); + }); + + describe("Decimal", () => { + test("encode", () => { + const d = new Decimal("3.14159"); + expect(codec.encode(d)).toEqual({ $decimal: "3.14159" }); + }); + + test("round-trip", () => { + const d = new Decimal("123456789.987654321"); + const result = roundTrip(d); + expect(result).toBeInstanceOf(Decimal); + expect((result as Decimal).toString()).toBe("123456789.987654321"); + }); + }); + + describe("Duration", () => { + test("encode", () => { + const d = new Duration("1h30m"); + expect(codec.encode(d)).toEqual({ $duration: d.toString() }); + }); + + test("round-trip", () => { + const d = new Duration("2d12h30m15s"); + const result = roundTrip(d); + expect(result).toBeInstanceOf(Duration); + expect((result as Duration).toString()).toBe(d.toString()); + }); + }); + + describe("Uuid", () => { + test("encode", () => { + const uuid = new Uuid("d2f72714-a387-487a-8eae-451330796ff4"); + expect(codec.encode(uuid)).toEqual({ + $uuid: "d2f72714-a387-487a-8eae-451330796ff4", + }); + }); + + test("round-trip", () => { + const uuid = new Uuid("d2f72714-a387-487a-8eae-451330796ff4"); + const result = roundTrip(uuid); + expect(result).toBeInstanceOf(Uuid); + expect((result as Uuid).toString()).toBe(uuid.toString()); + }); + }); + + describe("RecordId", () => { + test("encode with string id", () => { + const rid = new RecordId("users", "bob"); + expect(codec.encode(rid)).toEqual({ + $recordId: { tb: "users", id: "bob" }, + }); + }); + + test("encode with numeric id", () => { + const rid = new RecordId("users", 42); + expect(codec.encode(rid)).toEqual({ + $recordId: { tb: "users", id: 42 }, + }); + }); + + test("encode with object id", () => { + const rid = new RecordId("events", { city: "London", year: 2024 }); + const encoded = codec.encode(rid) as { $recordId: { tb: string; id: unknown } }; + expect(encoded.$recordId.tb).toBe("events"); + expect(encoded.$recordId.id).toEqual({ city: "London", year: 2024 }); + }); + + test("encode with array id", () => { + const rid = new RecordId("matrix", ["a", "b"]); + const encoded = codec.encode(rid) as { $recordId: { tb: string; id: unknown } }; + expect(encoded.$recordId.tb).toBe("matrix"); + expect(encoded.$recordId.id).toEqual(["a", "b"]); + }); + + test("round-trip with string id", () => { + const rid = new RecordId("users", "bob"); + const result = roundTrip(rid); + expect(result).toBeInstanceOf(RecordId); + expect((result as RecordId).table.name).toBe("users"); + }); + + test("round-trip with numeric id", () => { + const rid = new RecordId("users", 42); + const result = roundTrip(rid); + expect(result).toBeInstanceOf(RecordId); + expect((result as RecordId).table.name).toBe("users"); + }); + }); + + describe("StringRecordId", () => { + test("encode", () => { + const srid = new StringRecordId("users:bob"); + expect(codec.encode(srid)).toEqual({ $recordIdString: "users:bob" }); + }); + + test("round-trip", () => { + const srid = new StringRecordId("users:bob"); + const result = roundTrip(srid); + expect(result).toBeInstanceOf(StringRecordId); + expect((result as StringRecordId).toString()).toBe("users:bob"); + }); + }); + + describe("RecordIdRange", () => { + test("encode", () => { + const range = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(100)); + expect(codec.encode(range)).toEqual({ + $recordId: { + tb: "users", + id: { + $range: { + begin: { $boundIncluded: 1 }, + end: { $boundExcluded: 100 }, + }, + }, + }, + }); + }); + + test("round-trip", () => { + const range = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(100)); + const result = roundTrip(range); + expect(result).toBeInstanceOf(RecordIdRange); + const decoded = result as RecordIdRange; + expect(decoded.table.name).toBe("users"); + expect(decoded.begin).toBeInstanceOf(BoundIncluded); + expect(decoded.begin?.value).toBe(1); + expect(decoded.end).toBeInstanceOf(BoundExcluded); + expect(decoded.end?.value).toBe(100); + }); + }); + + describe("Table", () => { + test("encode", () => { + expect(codec.encode(new Table("users"))).toEqual({ $table: "users" }); + }); + + test("round-trip", () => { + const result = roundTrip(new Table("users")); + expect(result).toBeInstanceOf(Table); + expect((result as Table).name).toBe("users"); + }); + }); + + describe("Range", () => { + test("encode with both bounds", () => { + const range = new Range(new BoundIncluded(1), new BoundExcluded(10)); + expect(codec.encode(range)).toEqual({ + $range: { + begin: { $boundIncluded: 1 }, + end: { $boundExcluded: 10 }, + }, + }); + }); + + test("encode with undefined bounds", () => { + const range = new Range(undefined, new BoundIncluded(10)); + expect(codec.encode(range)).toEqual({ + $range: { + begin: null, + end: { $boundIncluded: 10 }, + }, + }); + }); + + test("round-trip", () => { + const range = new Range(new BoundIncluded(5), new BoundExcluded(50)); + const result = roundTrip(range); + expect(result).toBeInstanceOf(Range); + const decoded = result as Range; + expect(decoded.begin).toBeInstanceOf(BoundIncluded); + expect(decoded.begin?.value).toBe(5); + expect(decoded.end).toBeInstanceOf(BoundExcluded); + expect(decoded.end?.value).toBe(50); + }); + }); + + describe("FileRef", () => { + test("encode", () => { + const file = new FileRef("images", "/avatar.png"); + expect(codec.encode(file)).toEqual({ + $file: { bucket: "images", key: "/avatar.png" }, + }); + }); + + test("round-trip", () => { + const file = new FileRef("docs", "/report.pdf"); + const result = roundTrip(file); + expect(result).toBeInstanceOf(FileRef); + expect((result as FileRef).bucket).toBe("docs"); + expect((result as FileRef).key).toBe("/report.pdf"); + }); + }); + + describe("Future", () => { + test("encode", () => { + const future = new Future("{ RETURN 42 }"); + expect(codec.encode(future)).toEqual({ $future: "{ RETURN 42 }" }); + }); + + test("round-trip", () => { + const future = new Future("{ time::now() }"); + const result = roundTrip(future); + expect(result).toBeInstanceOf(Future); + expect((result as Future).body).toBe("{ time::now() }"); + }); + }); + + describe("Uint8Array", () => { + test("encode", () => { + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + const encoded = codec.encode(bytes) as { $bytes: string }; + expect(encoded.$bytes).toBeTypeOf("string"); + }); + + test("round-trip", () => { + const bytes = new Uint8Array([0, 1, 2, 255]); + const result = roundTrip(bytes); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(bytes); + }); + }); + + describe("Geometry", () => { + test("encode Point", () => { + const point = new GeometryPoint([1.5, 2.5]); + expect(codec.encode(point)).toEqual({ + $geometry: { type: "Point", coordinates: [1.5, 2.5] }, + }); + }); + + test("round-trip Point", () => { + const point = new GeometryPoint([10, 20]); + const result = roundTrip(point); + expect(result).toBeInstanceOf(GeometryPoint); + expect((result as GeometryPoint).point).toEqual([10, 20]); + }); + + test("round-trip LineString", () => { + const line = new GeometryLine([new GeometryPoint([0, 0]), new GeometryPoint([1, 1])]); + const result = roundTrip(line); + expect(result).toBeInstanceOf(GeometryLine); + const decoded = result as GeometryLine; + expect(decoded.line).toHaveLength(2); + expect(decoded.line[0].point).toEqual([0, 0]); + expect(decoded.line[1].point).toEqual([1, 1]); + }); + + test("round-trip Polygon", () => { + const poly = new GeometryPolygon([ + new GeometryLine([ + new GeometryPoint([0, 0]), + new GeometryPoint([1, 0]), + new GeometryPoint([1, 1]), + new GeometryPoint([0, 0]), + ]), + ]); + const result = roundTrip(poly); + expect(result).toBeInstanceOf(GeometryPolygon); + }); + + test("round-trip MultiPoint", () => { + const mp = new GeometryMultiPoint([ + new GeometryPoint([0, 0]), + new GeometryPoint([1, 1]), + ]); + const result = roundTrip(mp); + expect(result).toBeInstanceOf(GeometryMultiPoint); + expect((result as GeometryMultiPoint).points).toHaveLength(2); + }); + + test("round-trip MultiLineString", () => { + const ml = new GeometryMultiLine([ + new GeometryLine([new GeometryPoint([0, 0]), new GeometryPoint([1, 1])]), + ]); + const result = roundTrip(ml); + expect(result).toBeInstanceOf(GeometryMultiLine); + }); + + test("round-trip MultiPolygon", () => { + const mpoly = new GeometryMultiPolygon([ + new GeometryPolygon([ + new GeometryLine([ + new GeometryPoint([0, 0]), + new GeometryPoint([1, 0]), + new GeometryPoint([1, 1]), + new GeometryPoint([0, 0]), + ]), + ]), + ]); + const result = roundTrip(mpoly); + expect(result).toBeInstanceOf(GeometryMultiPolygon); + }); + + test("round-trip GeometryCollection", () => { + const coll = new GeometryCollection([ + new GeometryPoint([1, 2]), + new GeometryLine([new GeometryPoint([0, 0]), new GeometryPoint([3, 3])]), + ]); + const result = roundTrip(coll); + expect(result).toBeInstanceOf(GeometryCollection); + const decoded = result as GeometryCollection; + expect(decoded.collection).toHaveLength(2); + expect(decoded.collection[0]).toBeInstanceOf(GeometryPoint); + expect(decoded.collection[1]).toBeInstanceOf(GeometryLine); + }); + }); + + describe("Set", () => { + test("encode", () => { + const s = new Set([1, 2, 3]); + expect(codec.encode(s)).toEqual({ $set: [1, 2, 3] }); + }); + + test("round-trip", () => { + const s = new Set(["a", "b", "c"]); + const result = roundTrip(s); + expect(result).toBeInstanceOf(Set); + expect(result).toEqual(new Set(["a", "b", "c"])); + }); + + test("round-trip with nested values", () => { + const s = new Set([new Decimal("1.5"), new Decimal("2.5")]); + const result = roundTrip(s) as Set; + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(2); + for (const v of result) { + expect(v).toBeInstanceOf(Decimal); + } + }); + }); + + describe("containers", () => { + test("array", () => { + const arr = [1, "two", new Decimal("3")]; + const result = roundTrip(arr) as unknown[]; + expect(result[0]).toBe(1); + expect(result[1]).toBe("two"); + expect(result[2]).toBeInstanceOf(Decimal); + }); + + test("object", () => { + const obj = { name: "Alice", age: 30 }; + expect(roundTrip(obj)).toEqual({ name: "Alice", age: 30 }); + }); + + test("nested values in objects", () => { + const obj = { + id: new RecordId("users", "alice"), + created: new DateTime("2024-01-01T00:00:00Z"), + }; + const result = roundTrip(obj) as Record; + expect(result.id).toBeInstanceOf(RecordId); + expect(result.created).toBeInstanceOf(DateTime); + }); + + test("Map", () => { + const map = new Map([ + ["key1", 1], + ["key2", "value"], + ]); + const result = roundTrip(map); + expect(result).toEqual({ key1: 1, key2: "value" }); + }); + }); + + describe("explicit objects", () => { + test("encode wraps objects with $-prefixed keys", () => { + const obj = { $foo: "bar", $baz: 42 }; + expect(codec.encode(obj)).toEqual({ + $object: { $foo: "bar", $baz: 42 }, + }); + }); + + test("encode does not wrap objects without $-prefixed keys", () => { + const obj = { name: "Alice", age: 30 }; + expect(codec.encode(obj)).toEqual({ name: "Alice", age: 30 }); + }); + + test("decode unwraps $object to plain object", () => { + const input = { $object: { $foo: "bar" } }; + expect(codec.decode<{ $foo: string }>(input)).toEqual({ $foo: "bar" }); + }); + + test("round-trip preserves $-prefixed keys", () => { + const obj = { $foo: "bar", regular: "value" }; + const result = roundTrip(obj); + expect(result).toEqual({ $foo: "bar", regular: "value" }); + }); + + test("nested SQON values inside $object are still deserialized", () => { + const input = { + $object: { + $custom: { $datetime: "2024-01-15T10:30:00.000Z" }, + }, + }; + const result = codec.decode(input) as Record; + expect(result.$custom).toBeInstanceOf(DateTime); + }); + + test("encode wraps when only some keys are $-prefixed", () => { + const obj = { $meta: true, name: "test" }; + expect(codec.encode(obj)).toEqual({ + $object: { $meta: true, name: "test" }, + }); + }); + + test("round-trip with nested objects containing $-prefixed keys", () => { + const obj = { + outer: { $inner: "value" }, + }; + const result = roundTrip(obj) as Record; + expect(result.outer).toEqual({ $inner: "value" }); + }); + }); + + describe("visitors", () => { + test("valueEncodeVisitor", () => { + const visitor = new JsonCodec({ + valueEncodeVisitor: (v) => { + if (typeof v === "string") return v.toUpperCase(); + return v; + }, + }); + + expect(visitor.encode("hello")).toBe("HELLO"); + }); + + test("valueDecodeVisitor", () => { + const visitor = new JsonCodec({ + valueDecodeVisitor: (v) => { + if (v instanceof Table) return v.name; + return v; + }, + }); + + expect(visitor.decode({ $table: "users" })).toBe("users"); + }); + }); +}); diff --git a/packages/tests/unit/sqon/errors.test.ts b/packages/tests/unit/sqon/errors.test.ts new file mode 100644 index 00000000..fae7857b --- /dev/null +++ b/packages/tests/unit/sqon/errors.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { + InvalidDateError, + InvalidDecimalError, + InvalidDurationError, + InvalidRecordIdError, + InvalidTableError, + SqonError, +} from "surrealdb"; + +describe("SQON errors", () => { + test("SqonError is the base error class", () => { + const error = new SqonError("test"); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(SqonError); + expect(error.message).toBe("test"); + }); + + test("InvalidDateError accepts a message", () => { + const error = new InvalidDateError("invalid datetime"); + expect(error).toBeInstanceOf(SqonError); + expect(error.name).toBe("InvalidDateError"); + expect(error.message).toBe("invalid datetime"); + }); + + test("InvalidDateError accepts an invalid Date", () => { + const error = new InvalidDateError(new Date(NaN)); + expect(error).toBeInstanceOf(SqonError); + expect(error.message).toContain("invalid"); + }); + + test("InvalidRecordIdError", () => { + const error = new InvalidRecordIdError("ID part is not valid"); + expect(error).toBeInstanceOf(SqonError); + expect(error.name).toBe("InvalidRecordIdError"); + }); + + test("InvalidDurationError", () => { + const error = new InvalidDurationError(); + expect(error).toBeInstanceOf(SqonError); + expect(error.name).toBe("InvalidDurationError"); + }); + + test("InvalidDecimalError", () => { + const error = new InvalidDecimalError(); + expect(error).toBeInstanceOf(SqonError); + expect(error.name).toBe("InvalidDecimalError"); + }); + + test("InvalidTableError", () => { + const error = new InvalidTableError(); + expect(error).toBeInstanceOf(SqonError); + expect(error.name).toBe("InvalidTableError"); + }); +}); diff --git a/packages/tests/unit/values/datetime.test.ts b/packages/tests/unit/sqon/values/datetime.test.ts similarity index 97% rename from packages/tests/unit/values/datetime.test.ts rename to packages/tests/unit/sqon/values/datetime.test.ts index 75cee25d..f8a1cced 100644 --- a/packages/tests/unit/values/datetime.test.ts +++ b/packages/tests/unit/sqon/values/datetime.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { DateTime } from "../../../sdk/src/value/datetime"; -import { Duration } from "../../../sdk/src/value/duration"; +import { DateTime, Duration } from "surrealdb"; describe("DateTime", () => { test("constructor with Date object", () => { diff --git a/packages/tests/unit/values/decimal.test.ts b/packages/tests/unit/sqon/values/decimal.test.ts similarity index 100% rename from packages/tests/unit/values/decimal.test.ts rename to packages/tests/unit/sqon/values/decimal.test.ts diff --git a/packages/tests/unit/values/duration.test.ts b/packages/tests/unit/sqon/values/duration.test.ts similarity index 99% rename from packages/tests/unit/values/duration.test.ts rename to packages/tests/unit/sqon/values/duration.test.ts index 5d996191..d810478e 100644 --- a/packages/tests/unit/values/duration.test.ts +++ b/packages/tests/unit/sqon/values/duration.test.ts @@ -4,6 +4,7 @@ import { Duration } from "surrealdb"; describe("Duration", () => { test("string equality", () => { + expect(new Duration("0ns").toString()).toBe("0ns"); expect(new Duration("1ns").toString()).toBe("1ns"); expect(new Duration("1us").toString()).toBe("1us"); expect(new Duration("1ms").toString()).toBe("1ms"); diff --git a/packages/tests/unit/values/file.test.ts b/packages/tests/unit/sqon/values/file.test.ts similarity index 100% rename from packages/tests/unit/values/file.test.ts rename to packages/tests/unit/sqon/values/file.test.ts diff --git a/packages/tests/unit/sqon/values/geometry.test.ts b/packages/tests/unit/sqon/values/geometry.test.ts new file mode 100644 index 00000000..5bd9f8bd --- /dev/null +++ b/packages/tests/unit/sqon/values/geometry.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, test } from "bun:test"; +import { + Geometry, + GeometryCollection, + GeometryLine, + GeometryMultiLine, + GeometryMultiPoint, + GeometryMultiPolygon, + GeometryPoint, + GeometryPolygon, +} from "surrealdb"; + +describe("GeometryPoint", () => { + test("construct from coordinates", () => { + const point = new GeometryPoint([1.5, 2.5]); + expect(point.point).toEqual([1.5, 2.5]); + }); + + test("construct from GeoJSON", () => { + const point = new GeometryPoint({ type: "Point" as const, coordinates: [10, 20] }); + expect(point.point).toEqual([10, 20]); + }); + + test("clone", () => { + const point = new GeometryPoint([5, 10]); + const cloned = point.clone(); + expect(cloned.point).toEqual([5, 10]); + expect(cloned).not.toBe(point); + }); + + test("toJSON", () => { + const point = new GeometryPoint([1, 2]); + expect(point.toJSON()).toEqual({ type: "Point", coordinates: [1, 2] }); + }); + + test("equals", () => { + const a = new GeometryPoint([1, 2]); + const b = new GeometryPoint([1, 2]); + const c = new GeometryPoint([3, 4]); + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); + + test("toString", () => { + const point = new GeometryPoint([1, 2]); + expect(point.toString()).toBe('{"type":"Point","coordinates":[1,2]}'); + }); +}); + +describe("GeometryLine", () => { + const p1 = new GeometryPoint([0, 0]); + const p2 = new GeometryPoint([1, 1]); + const p3 = new GeometryPoint([2, 0]); + + test("construct from points", () => { + const line = new GeometryLine([p1, p2, p3]); + expect(line.line).toHaveLength(3); + }); + + test("construct from GeoJSON", () => { + const line = new GeometryLine({ + type: "LineString" as const, + coordinates: [ + [0, 0], + [1, 1], + [2, 0], + ] as [[number, number], [number, number], ...[number, number][]], + }); + expect(line.line).toHaveLength(3); + expect(line.line[0].point).toEqual([0, 0]); + }); + + test("clone", () => { + const line = new GeometryLine([p1, p2]); + const cloned = line.clone(); + expect(cloned.line).toHaveLength(2); + expect(cloned).not.toBe(line); + }); + + test("toJSON", () => { + const line = new GeometryLine([p1, p2]); + const json = line.toJSON(); + expect(json.type).toBe("LineString"); + expect(json.coordinates).toHaveLength(2); + }); + + test("equals", () => { + const a = new GeometryLine([p1, p2]); + const b = new GeometryLine([new GeometryPoint([0, 0]), new GeometryPoint([1, 1])]); + const c = new GeometryLine([p1, p3]); + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); +}); + +describe("GeometryPolygon", () => { + const ring = new GeometryLine([ + new GeometryPoint([0, 0]), + new GeometryPoint([1, 0]), + new GeometryPoint([1, 1]), + new GeometryPoint([0, 0]), + ]); + + test("construct from lines", () => { + const poly = new GeometryPolygon([ring]); + expect(poly.polygon).toHaveLength(1); + }); + + test("construct from GeoJSON", () => { + const poly = new GeometryPolygon({ + type: "Polygon" as const, + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ] as [[number, number], [number, number], ...[number, number][]], + ] as [[[number, number], [number, number], ...[number, number][]]], + }); + expect(poly.polygon).toHaveLength(1); + }); + + test("clone", () => { + const poly = new GeometryPolygon([ring]); + const cloned = poly.clone(); + expect(cloned.polygon).toHaveLength(1); + expect(cloned).not.toBe(poly); + }); + + test("toJSON", () => { + const poly = new GeometryPolygon([ring]); + const json = poly.toJSON(); + expect(json.type).toBe("Polygon"); + }); + + test("equals", () => { + const a = new GeometryPolygon([ring]); + const b = new GeometryPolygon([ring.clone()]); + expect(a.equals(b)).toBe(true); + }); +}); + +describe("GeometryMultiPoint", () => { + const p1 = new GeometryPoint([0, 0]); + const p2 = new GeometryPoint([1, 1]); + + test("construct from points", () => { + const mp = new GeometryMultiPoint([p1, p2]); + expect(mp.points).toHaveLength(2); + }); + + test("construct from GeoJSON", () => { + const mp = new GeometryMultiPoint({ + type: "MultiPoint" as const, + coordinates: [ + [0, 0], + [1, 1], + ] as [[number, number], ...[number, number][]], + }); + expect(mp.points).toHaveLength(2); + expect(mp.points[0].point).toEqual([0, 0]); + }); + + test("toJSON", () => { + const mp = new GeometryMultiPoint([p1, p2]); + const json = mp.toJSON(); + expect(json.type).toBe("MultiPoint"); + expect(json.coordinates).toHaveLength(2); + }); + + test("equals", () => { + const a = new GeometryMultiPoint([p1, p2]); + const b = new GeometryMultiPoint([new GeometryPoint([0, 0]), new GeometryPoint([1, 1])]); + expect(a.equals(b)).toBe(true); + }); +}); + +describe("GeometryMultiLine", () => { + const line = new GeometryLine([new GeometryPoint([0, 0]), new GeometryPoint([1, 1])]); + + test("construct from lines", () => { + const ml = new GeometryMultiLine([line]); + expect(ml.lines).toHaveLength(1); + }); + + test("construct from GeoJSON", () => { + const ml = new GeometryMultiLine({ + type: "MultiLineString" as const, + coordinates: [ + [ + [0, 0], + [1, 1], + ] as [[number, number], [number, number], ...[number, number][]], + ] as [[[number, number], [number, number], ...[number, number][]]], + }); + expect(ml.lines).toHaveLength(1); + }); + + test("toJSON", () => { + const ml = new GeometryMultiLine([line]); + const json = ml.toJSON(); + expect(json.type).toBe("MultiLineString"); + }); +}); + +describe("GeometryMultiPolygon", () => { + const ring = new GeometryLine([ + new GeometryPoint([0, 0]), + new GeometryPoint([1, 0]), + new GeometryPoint([1, 1]), + new GeometryPoint([0, 0]), + ]); + const poly = new GeometryPolygon([ring]); + + test("construct from polygons", () => { + const mpoly = new GeometryMultiPolygon([poly]); + expect(mpoly.polygons).toHaveLength(1); + }); + + test("toJSON", () => { + const mpoly = new GeometryMultiPolygon([poly]); + const json = mpoly.toJSON(); + expect(json.type).toBe("MultiPolygon"); + }); +}); + +describe("GeometryCollection", () => { + const point = new GeometryPoint([1, 2]); + const line = new GeometryLine([new GeometryPoint([0, 0]), new GeometryPoint([3, 3])]); + + test("construct from geometries", () => { + const coll = new GeometryCollection([point, line]); + expect(coll.collection).toHaveLength(2); + }); + + test("construct from GeoJSON", () => { + const coll = new GeometryCollection({ + type: "GeometryCollection" as const, + geometries: [{ type: "Point" as const, coordinates: [1, 2] as [number, number] }], + }); + expect(coll.collection).toHaveLength(1); + expect(coll.collection[0]).toBeInstanceOf(GeometryPoint); + }); + + test("toJSON", () => { + const coll = new GeometryCollection([point, line]); + const json = coll.toJSON(); + expect(json.type).toBe("GeometryCollection"); + expect(json.geometries).toHaveLength(2); + }); + + test("equals", () => { + const a = new GeometryCollection([point, line]); + const b = new GeometryCollection([point.clone(), line.clone()]); + expect(a.equals(b)).toBe(true); + }); +}); + +describe("Geometry.fromJSON", () => { + test("dispatches Point", () => { + const result = Geometry.fromJSON({ type: "Point", coordinates: [1, 2] }); + expect(result).toBeInstanceOf(GeometryPoint); + }); + + test("dispatches LineString", () => { + const result = Geometry.fromJSON({ + type: "LineString", + coordinates: [ + [0, 0], + [1, 1], + ], + } as { type: "LineString"; coordinates: [[number, number], [number, number]] }); + expect(result).toBeInstanceOf(GeometryLine); + }); + + test("dispatches Polygon", () => { + const result = Geometry.fromJSON({ + type: "Polygon", + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + } as { + type: "Polygon"; + coordinates: [[[number, number], [number, number], ...[number, number][]]]; + }); + expect(result).toBeInstanceOf(GeometryPolygon); + }); + + test("dispatches MultiPoint", () => { + const result = Geometry.fromJSON({ + type: "MultiPoint", + coordinates: [ + [0, 0], + [1, 1], + ], + } as { type: "MultiPoint"; coordinates: [[number, number], ...[number, number][]] }); + expect(result).toBeInstanceOf(GeometryMultiPoint); + }); + + test("dispatches GeometryCollection", () => { + const result = Geometry.fromJSON({ + type: "GeometryCollection", + geometries: [{ type: "Point", coordinates: [1, 2] }], + }); + expect(result).toBeInstanceOf(GeometryCollection); + }); +}); diff --git a/packages/tests/unit/sqon/values/range.test.ts b/packages/tests/unit/sqon/values/range.test.ts new file mode 100644 index 00000000..ac5c2ec7 --- /dev/null +++ b/packages/tests/unit/sqon/values/range.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { BoundExcluded, BoundIncluded, Range } from "surrealdb"; + +describe("Range", () => { + test("construct with both bounds", () => { + const range = new Range(new BoundIncluded(1), new BoundExcluded(10)); + expect(range.begin).toBeInstanceOf(BoundIncluded); + expect(range.begin?.value).toBe(1); + expect(range.end).toBeInstanceOf(BoundExcluded); + expect(range.end?.value).toBe(10); + }); + + test("construct with undefined begin", () => { + const range = new Range(undefined, new BoundIncluded(10)); + expect(range.begin).toBeUndefined(); + expect(range.end).toBeInstanceOf(BoundIncluded); + }); + + test("construct with undefined end", () => { + const range = new Range(new BoundExcluded(5), undefined); + expect(range.begin).toBeInstanceOf(BoundExcluded); + expect(range.end).toBeUndefined(); + }); + + test("construct fully unbounded", () => { + const range = new Range(undefined, undefined); + expect(range.begin).toBeUndefined(); + expect(range.end).toBeUndefined(); + }); + + test("equals with matching bounds", () => { + const a = new Range(new BoundIncluded(1), new BoundExcluded(10)); + const b = new Range(new BoundIncluded(1), new BoundExcluded(10)); + expect(a.equals(b)).toBe(true); + }); + + test("not equals with different values", () => { + const a = new Range(new BoundIncluded(1), new BoundExcluded(10)); + const b = new Range(new BoundIncluded(2), new BoundExcluded(10)); + expect(a.equals(b)).toBe(false); + }); + + test("not equals with different bound types", () => { + const a = new Range(new BoundIncluded(1), new BoundExcluded(10)); + const b = new Range(new BoundExcluded(1), new BoundExcluded(10)); + expect(a.equals(b)).toBe(false); + }); + + test("not equals with non-Range", () => { + const range = new Range(new BoundIncluded(1), new BoundExcluded(10)); + expect(range.equals("not a range")).toBe(false); + }); + + test("toString", () => { + const range = new Range(new BoundIncluded(1), new BoundExcluded(10)); + const str = range.toString(); + expect(typeof str).toBe("string"); + expect(str.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/tests/unit/sqon/values/record-id-range.test.ts b/packages/tests/unit/sqon/values/record-id-range.test.ts new file mode 100644 index 00000000..a42b53a6 --- /dev/null +++ b/packages/tests/unit/sqon/values/record-id-range.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { BoundExcluded, BoundIncluded, RecordIdRange } from "surrealdb"; + +describe("RecordIdRange", () => { + test("construct with included/excluded bounds", () => { + const range = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(100)); + expect(range.table.name).toBe("users"); + expect(range.begin).toBeInstanceOf(BoundIncluded); + expect(range.begin?.value).toBe(1); + expect(range.end).toBeInstanceOf(BoundExcluded); + expect(range.end?.value).toBe(100); + }); + + test("construct with unbounded begin", () => { + const range = new RecordIdRange("users", undefined, new BoundIncluded(50)); + expect(range.begin).toBeUndefined(); + expect(range.end).toBeInstanceOf(BoundIncluded); + }); + + test("equals", () => { + const a = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(100)); + const b = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(100)); + expect(a.equals(b)).toBe(true); + }); + + test("not equals with different table", () => { + const a = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(100)); + const b = new RecordIdRange("posts", new BoundIncluded(1), new BoundExcluded(100)); + expect(a.equals(b)).toBe(false); + }); + + test("not equals with different bounds", () => { + const a = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(100)); + const b = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(200)); + expect(a.equals(b)).toBe(false); + }); + + test("not equals with non-RecordIdRange", () => { + const range = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(100)); + expect(range.equals("not a range")).toBe(false); + }); + + test("toString", () => { + const range = new RecordIdRange("users", new BoundIncluded(1), new BoundExcluded(100)); + const str = range.toString(); + expect(typeof str).toBe("string"); + expect(str).toContain("users"); + }); +}); diff --git a/packages/tests/unit/values/record-id.test.ts b/packages/tests/unit/sqon/values/record-id.test.ts similarity index 100% rename from packages/tests/unit/values/record-id.test.ts rename to packages/tests/unit/sqon/values/record-id.test.ts diff --git a/packages/tests/unit/sqon/values/string-record-id.test.ts b/packages/tests/unit/sqon/values/string-record-id.test.ts new file mode 100644 index 00000000..d1c010f9 --- /dev/null +++ b/packages/tests/unit/sqon/values/string-record-id.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { InvalidRecordIdError, RecordId, StringRecordId } from "surrealdb"; + +describe("StringRecordId", () => { + test("construct from string", () => { + const srid = new StringRecordId("users:bob"); + expect(srid.toString()).toBe("users:bob"); + }); + + test("construct from StringRecordId", () => { + const original = new StringRecordId("users:bob"); + const cloned = new StringRecordId(original); + expect(cloned.toString()).toBe("users:bob"); + }); + + test("construct from RecordId", () => { + const rid = new RecordId("users", "bob"); + const srid = new StringRecordId(rid); + expect(srid.toString()).toBe(rid.toString()); + }); + + test("rejects non-string", () => { + // @ts-expect-error + expect(() => new StringRecordId(123)).toThrow(InvalidRecordIdError); + }); + + test("equals", () => { + const a = new StringRecordId("users:bob"); + const b = new StringRecordId("users:bob"); + const c = new StringRecordId("users:alice"); + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + expect(a.equals("users:bob")).toBe(false); + }); +}); diff --git a/packages/tests/unit/sqon/values/table.test.ts b/packages/tests/unit/sqon/values/table.test.ts new file mode 100644 index 00000000..9b55e071 --- /dev/null +++ b/packages/tests/unit/sqon/values/table.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { Table } from "surrealdb"; + +describe("Table", () => { + test("construct from string", () => { + const table = new Table("users"); + expect(table.name).toBe("users"); + }); + + test("rejects non-string", () => { + // @ts-expect-error + expect(() => new Table(123)).toThrow(); + }); + + test("toString escapes identifiers", () => { + expect(new Table("users").toString()).toBe("users"); + expect(new Table("complex-table").toString()).toBe("⟨complex-table⟩"); + }); + + test("equals", () => { + const a = new Table("users"); + const b = new Table("users"); + const c = new Table("posts"); + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + expect(a.equals("users")).toBe(false); + }); + + test("name getter returns unescaped name", () => { + const table = new Table("complex-table"); + expect(table.name).toBe("complex-table"); + }); +}); diff --git a/packages/tests/type-tests/values.test.ts b/packages/tests/unit/sqon/values/types.test.ts similarity index 100% rename from packages/tests/type-tests/values.test.ts rename to packages/tests/unit/sqon/values/types.test.ts diff --git a/packages/tests/unit/sqon/values/uuid.test.ts b/packages/tests/unit/sqon/values/uuid.test.ts new file mode 100644 index 00000000..c2b30d49 --- /dev/null +++ b/packages/tests/unit/sqon/values/uuid.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { Uuid } from "surrealdb"; + +describe("Uuid", () => { + const EXAMPLE = "d2f72714-a387-487a-8eae-451330796ff4"; + + test("construct from string", () => { + const uuid = new Uuid(EXAMPLE); + expect(uuid.toString()).toBe(EXAMPLE); + }); + + test("construct from Uint8Array", () => { + const original = new Uuid(EXAMPLE); + const bytes = original.toUint8Array(); + const restored = new Uuid(bytes); + expect(restored.toString()).toBe(EXAMPLE); + }); + + test("construct from ArrayBuffer", () => { + const original = new Uuid(EXAMPLE); + const buffer = original.toBuffer(); + const restored = new Uuid(buffer); + expect(restored.toString()).toBe(EXAMPLE); + }); + + test("clone from Uuid", () => { + const original = new Uuid(EXAMPLE); + const cloned = new Uuid(original); + expect(cloned.toString()).toBe(EXAMPLE); + expect(cloned.equals(original)).toBe(true); + }); + + test("equals", () => { + const a = new Uuid(EXAMPLE); + const b = new Uuid(EXAMPLE); + expect(a.equals(b)).toBe(true); + expect(a.equals(new Uuid(Uuid.v4()))).toBe(false); + expect(a.equals("not a uuid")).toBe(false); + }); + + test("v4 generates valid uuid", () => { + const uuid = Uuid.v4(); + expect(uuid).toBeInstanceOf(Uuid); + expect(uuid.toString()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + test("v7 generates valid uuid", () => { + const uuid = Uuid.v7(); + expect(uuid).toBeInstanceOf(Uuid); + expect(uuid.toString()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + test("toUint8Array returns 16 bytes", () => { + const uuid = new Uuid(EXAMPLE); + const bytes = uuid.toUint8Array(); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes.length).toBe(16); + }); +}); diff --git a/packages/wasm/src-ts/engine.ts b/packages/wasm/src-ts/engine.ts index 15fd8535..15c404f0 100644 --- a/packages/wasm/src-ts/engine.ts +++ b/packages/wasm/src-ts/engine.ts @@ -19,6 +19,7 @@ import { } from "surrealdb"; import type { ConnectionOptions } from "../wasm/surrealdb"; import type { EngineBroker } from "./common"; +import { wrapSqonError } from "./wrap-sqon-error"; type LiveChannels = Record; @@ -104,10 +105,12 @@ export class WebAssemblyEngine extends RpcEngine implements SurrealEngine { } const id = this._context.uniqueId(); - const payload = this._context.codecs.cbor.encode({ id, ...request }); + const payload = wrapSqonError(() => this._context.codecs.cbor.encode({ id, ...request })); const response = await this.#broker.execute(payload); - const decoded = this._context.codecs.cbor.decode>(response); + const decoded = wrapSqonError(() => + this._context.codecs.cbor.decode>(response), + ); if (decoded && typeof decoded === "object" && "error" in decoded) { throw parseRpcError( @@ -151,7 +154,7 @@ export class WebAssemblyEngine extends RpcEngine implements SurrealEngine { } override async exportSql(options: Partial): Promise { - const payload = this._context.codecs.cbor.encode(options); + const payload = wrapSqonError(() => this._context.codecs.cbor.encode(options)); const sql = await this.#broker.exportSql(payload); return new Response(sql); @@ -160,7 +163,9 @@ export class WebAssemblyEngine extends RpcEngine implements SurrealEngine { async #initialize(state: ConnectionState, signal: AbortSignal) { try { await this.#broker.connect(state.url.toString(), this.#options, (data) => { - const payload = this._context.codecs.cbor.decode(data); + const payload = wrapSqonError(() => + this._context.codecs.cbor.decode(data), + ); if (payload.id) { this.#subscriptions.publish(payload.id.toString(), { diff --git a/packages/wasm/src-ts/wrap-sqon-error.ts b/packages/wasm/src-ts/wrap-sqon-error.ts new file mode 100644 index 00000000..b963aa0f --- /dev/null +++ b/packages/wasm/src-ts/wrap-sqon-error.ts @@ -0,0 +1,16 @@ +import { SqonError, SurrealSqonError } from "surrealdb"; + +/** + * Execute a function and wrap any thrown {@link SqonError} in a {@link SurrealSqonError}. + */ +export function wrapSqonError(fn: () => T): T { + try { + return fn(); + } catch (error) { + if (error instanceof SqonError) { + throw new SurrealSqonError(error); + } + + throw error; + } +} diff --git a/tsconfig.json b/tsconfig.json index 44544579..c060250b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,8 @@ "noImplicitOverride": true, "paths": { - "surrealdb": ["./packages/sdk/src"] + "surrealdb": ["./packages/sdk/src"], + "@surrealdb/sqon": ["./packages/sqon/src"] } } }