diff --git a/biome.json b/biome.json index 15fcc306..cb9b5a28 100644 --- a/biome.json +++ b/biome.json @@ -9,10 +9,18 @@ "recommended": true, "suspicious": { "noConsoleLog": "error" + }, + "style": { + "useFilenamingConvention": { + "level": "error", + "options": { + "filenameCases": ["kebab-case"] + } + } } } }, "files": { - "ignore": ["dist/**"] + "ignore": ["dist/**", "npm/**"] } } diff --git a/src/cbor/gap.ts b/src/cbor/gap.ts index 18848a30..f0bf5797 100644 --- a/src/cbor/gap.ts +++ b/src/cbor/gap.ts @@ -1,7 +1,15 @@ // Why is the default value being stored in an array? undefined, null, false, etc... are all valid defaults, // and specifying a field on a class as optional will make it undefined by default. +/** + * Represents a Gap and its intended value. + */ export type Fill = [Gap, T]; + +/** + * A Gap represents a placeholder value that can be filled + * at a later point in time. + */ export class Gap { readonly args: [T?] = []; constructor(...args: [T?]) { diff --git a/src/data/cbor.ts b/src/data/cbor.ts index 9ca285b9..b90f7391 100644 --- a/src/data/cbor.ts +++ b/src/data/cbor.ts @@ -1,11 +1,8 @@ -import { Tagged, decode, encode } from "../cbor"; import { cborCustomDateToDate, dateToCborCustomDate, } from "./types/datetime.ts"; -import { Decimal } from "./types/decimal.ts"; -import { Duration } from "./types/duration.ts"; -import { Future } from "./types/future.ts"; + import { GeometryCollection, GeometryLine, @@ -15,12 +12,18 @@ import { GeometryPoint, GeometryPolygon, } from "./types/geometry.ts"; + import { Range, RecordIdRange, cborToRange, rangeToCbor, } from "./types/range.ts"; + +import { Tagged, decode, encode } from "../cbor"; +import { Decimal } from "./types/decimal.ts"; +import { Duration } from "./types/duration.ts"; +import { Future } from "./types/future.ts"; import { RecordId, StringRecordId } from "./types/recordid.ts"; import { Table } from "./types/table.ts"; import { Uuid } from "./types/uuid.ts"; diff --git a/src/data/index.ts b/src/data/index.ts index 6ee3458e..2f943899 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -4,6 +4,7 @@ export { type RecordIdValue, escape_ident, } from "./types/recordid.ts"; + export { Range, type Bound, @@ -11,11 +12,7 @@ export { BoundExcluded, RecordIdRange, } from "./types/range.ts"; -export { Future } from "./types/future.ts"; -export { Uuid } from "./types/uuid.ts"; -export { Duration } from "./types/duration.ts"; -export { Decimal } from "./types/decimal.ts"; -export { Table } from "./types/table.ts"; + export { Geometry, GeometryCollection, @@ -26,4 +23,11 @@ export { GeometryPoint, GeometryPolygon, } from "./types/geometry.ts"; + +export { Future } from "./types/future.ts"; +export { Uuid } from "./types/uuid.ts"; +export { Duration } from "./types/duration.ts"; +export { Decimal } from "./types/decimal.ts"; +export { Table } from "./types/table.ts"; + export { encodeCbor, decodeCbor } from "./cbor.ts"; diff --git a/src/data/types/range.ts b/src/data/types/range.ts index 69b90a55..2cef7f7c 100644 --- a/src/data/types/range.ts +++ b/src/data/types/range.ts @@ -1,7 +1,3 @@ -import { Tagged } from "../../cbor"; -import { SurrealDbError } from "../../errors"; -import { toSurrealqlString } from "../../util/to-surrealql-string"; -import { TAG_BOUND_EXCLUDED, TAG_BOUND_INCLUDED, TAG_RANGE } from "../cbor"; import { type RecordIdValue, escape_id_part, @@ -9,6 +5,11 @@ import { isValidIdPart, } from "./recordid"; +import { Tagged } from "../../cbor"; +import { SurrealDbError } from "../../errors"; +import { toSurrealqlString } from "../../util/to-surrealql-string"; +import { TAG_BOUND_EXCLUDED, TAG_BOUND_INCLUDED } from "../cbor"; + export class Range { constructor( readonly beg: Bound, diff --git a/src/engines/abstract.ts b/src/engines/abstract.ts index 948a60bb..fa2bd1f3 100644 --- a/src/engines/abstract.ts +++ b/src/engines/abstract.ts @@ -1,12 +1,6 @@ -import type { Encoded } from "../cbor"; +import type { LiveHandlerArguments, RpcRequest, RpcResponse } from "../types"; + import type { EngineDisconnected } from "../errors"; -import type { - LiveAction, - LiveHandlerArguments, - Patch, - RpcRequest, - RpcResponse, -} from "../types"; import type { Emitter } from "../util/emitter"; export type Engine = new (context: EngineContext) => AbstractEngine; @@ -16,6 +10,7 @@ export type EngineEvents = { connecting: []; connected: []; disconnected: []; + reconnecting: []; error: [Error]; [K: `rpc-${string | number}`]: [RpcResponse | EngineDisconnected]; diff --git a/src/engines/http.ts b/src/engines/http.ts index d3aa54f5..8e9088ba 100644 --- a/src/engines/http.ts +++ b/src/engines/http.ts @@ -3,15 +3,17 @@ import { HttpConnectionError, MissingNamespaceDatabase, } from "../errors"; -import type { RpcRequest, RpcResponse } from "../types"; -import { getIncrementalID } from "../util/getIncrementalID"; -import { retrieveRemoteVersion } from "../util/versionCheck"; + import { AbstractEngine, ConnectionStatus, type EngineEvents, } from "./abstract"; +import type { RpcRequest, RpcResponse } from "../types"; +import { getIncrementalID } from "../util/get-incremental-id"; +import { retrieveRemoteVersion } from "../util/version-check"; + const ALWAYS_ALLOW = new Set([ "signin", "signup", diff --git a/src/engines/ws.ts b/src/engines/ws.ts index 4cbd9d7d..6ec8c79b 100644 --- a/src/engines/ws.ts +++ b/src/engines/ws.ts @@ -1,4 +1,3 @@ -import { WebSocket } from "isows"; import { ConnectionUnavailable, EngineDisconnected, @@ -6,9 +5,7 @@ import { UnexpectedConnectionError, UnexpectedServerResponse, } from "../errors"; -import { type RpcRequest, type RpcResponse, isLiveResult } from "../types"; -import { getIncrementalID } from "../util/getIncrementalID"; -import { retrieveRemoteVersion } from "../util/versionCheck"; + import { AbstractEngine, ConnectionStatus, @@ -16,6 +13,11 @@ import { type EngineEvents, } from "./abstract"; +import { WebSocket } from "isows"; +import { type RpcRequest, type RpcResponse, isLiveResult } from "../types"; +import { getIncrementalID } from "../util/get-incremental-id"; +import { retrieveRemoteVersion } from "../util/version-check"; + export class WebsocketEngine extends AbstractEngine { private pinger?: Pinger; private socket?: WebSocket; diff --git a/src/index.ts b/src/index.ts index 0722fbcf..de48e194 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { Emitter, type Listener, type UnknownEvents } from "./util/emitter.ts"; export { surql, surrealql } from "./util/tagged-template.ts"; -export { PreparedQuery } from "./util/PreparedQuery.ts"; +export { PreparedQuery } from "./util/prepared-query.ts"; + export * as cbor from "./cbor"; export * from "./cbor/gap"; export * from "./cbor/error"; @@ -8,14 +9,17 @@ export * from "./data"; export * from "./errors.ts"; export * from "./types.ts"; export * from "./util/jsonify.ts"; -export * from "./util/versionCheck.ts"; -export * from "./util/getIncrementalID.ts"; +export * from "./util/version-check.ts"; +export * from "./util/get-incremental-id.ts"; export * from "./util/string-prefixes.ts"; export * from "./util/to-surrealql-string.ts"; + export { ConnectionStatus, AbstractEngine, type Engine, + type Engines, type EngineEvents, } from "./engines/abstract.ts"; + export { Surreal, Surreal as default } from "./surreal.ts"; diff --git a/src/surreal.ts b/src/surreal.ts index c566d1aa..11f6e1f3 100644 --- a/src/surreal.ts +++ b/src/surreal.ts @@ -6,6 +6,7 @@ import { decodeCbor, encodeCbor, } from "./data"; + import { type AbstractEngine, ConnectionStatus, @@ -13,10 +14,6 @@ import { type EngineEvents, type Engines, } from "./engines/abstract.ts"; -import { PreparedQuery } from "./util/PreparedQuery.ts"; -import { Emitter } from "./util/emitter.ts"; -import { processAuthVars } from "./util/processAuthVars.ts"; -import { versionCheck } from "./util/versionCheck.ts"; import { type AccessRecordAuth, @@ -33,22 +30,25 @@ import { convertAuth, } from "./types.ts"; -import { type Fill, partiallyEncodeObject } from "./cbor"; -import { replacer } from "./data/cbor.ts"; -import type { RecordIdRange } from "./data/types/range.ts"; -import { HttpEngine } from "./engines/http.ts"; -import { WebsocketEngine } from "./engines/ws.ts"; import { EngineDisconnected, NoActiveSocket, - NoDatabaseSpecified, - NoNamespaceSpecified, NoTokenReturned, ResponseError, SurrealDbError, UnsupportedEngine, } from "./errors.ts"; +import { type Fill, partiallyEncodeObject } from "./cbor"; +import { replacer } from "./data/cbor.ts"; +import type { RecordIdRange } from "./data/types/range.ts"; +import { HttpEngine } from "./engines/http.ts"; +import { WebsocketEngine } from "./engines/ws.ts"; +import { Emitter } from "./util/emitter.ts"; +import { PreparedQuery } from "./util/prepared-query.ts"; +import { processAuthVars } from "./util/process-auth-vars.ts"; +import { versionCheck } from "./util/version-check.ts"; + type R = Prettify>; type RecordId = _RecordId | StringRecordId; @@ -56,6 +56,8 @@ export class Surreal { public connection: AbstractEngine | undefined; ready?: Promise; emitter: Emitter; + terminated = false; + reconnector?: unknown; protected engines: Engines = { ws: WebsocketEngine, wss: WebsocketEngine, @@ -82,7 +84,9 @@ export class Surreal { /** * Establish a socket connection to the database - * @param connection - Connection details + * + * @param url - The URL of the SurrealDB instance. + * @param opts - Options for the connection. */ async connect( url: string | URL, @@ -93,6 +97,8 @@ export class Surreal { prepare?: (connection: Surreal) => unknown; versionCheck?: boolean; versionCheckTimeout?: number; + reconnect?: boolean; + reconnectTimeout?: number; } = {}, ): Promise { // biome-ignore lint/style/noParameterAssign: Need to ensure it's a URL @@ -130,6 +136,17 @@ export class Surreal { versionCheck(version); } + // Schedule reconnect + if (opts.reconnect) { + const delay = opts.reconnectTimeout ?? 5000; + this.emitter.subscribeOnce(ConnectionStatus.Disconnected).then(() => { + if (this.terminated) return; + this.emitter.emit("reconnecting", []); + this.reconnector = setTimeout(() => this.connect(url, opts), delay); + }); + } + + this.terminated = false; this.connection = connection; this.ready = new Promise((resolve, reject) => connection @@ -162,6 +179,8 @@ export class Surreal { * Disconnect the socket to the database */ async close(): Promise { + clearTimeout(this.reconnector as number); + this.terminated = true; this.clean(); await this.connection?.disconnect(); return true; diff --git a/src/types.ts b/src/types.ts index 5f2d5b20..71d95159 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ -import type { Encoded, Fill } from "./cbor"; +import type { Fill } from "./cbor"; import { type RecordId, Uuid } from "./data"; import { SurrealDbError } from "./errors"; -import type { PreparedQuery } from "./util/PreparedQuery"; +import type { PreparedQuery } from "./util/prepared-query"; export type ActionResult> = Prettify< T["id"] extends RecordId ? T : { id: RecordId } & T diff --git a/src/util/emitter.ts b/src/util/emitter.ts index 12b81468..c44f00e4 100644 --- a/src/util/emitter.ts +++ b/src/util/emitter.ts @@ -1,8 +1,15 @@ +export type UnknownEvents = Record; + +/** + * A listener which can be used to subscribe to events. + */ export type Listener = ( ...args: Args ) => unknown; -export type UnknownEvents = Record; +/** + * An event emitter which can be used to subscribe to and emit events. + */ export class Emitter { private collectable: Partial<{ [K in keyof Events]: Events[K][]; diff --git a/src/util/getIncrementalID.ts b/src/util/get-incremental-id.ts similarity index 99% rename from src/util/getIncrementalID.ts rename to src/util/get-incremental-id.ts index e70381b5..f35501ea 100644 --- a/src/util/getIncrementalID.ts +++ b/src/util/get-incremental-id.ts @@ -1,4 +1,5 @@ let id = 0; + export function getIncrementalID(): string { id = (id + 1) % Number.MAX_SAFE_INTEGER; return id.toString(); diff --git a/src/util/jsonify.ts b/src/util/jsonify.ts index 1fb63c8e..071f1fad 100644 --- a/src/util/jsonify.ts +++ b/src/util/jsonify.ts @@ -38,6 +38,12 @@ export type Jsonify = T extends ? `${Tb}` : T; +/** + * Recursively converts SurrealQL values to a JSON serializable representation. + * + * @param input The structure to convert recursively. + * @returns JSON serializable representation of the input. + */ export function jsonify(input: T): Jsonify { if (typeof input === "object") { if (input === null) return null as Jsonify; diff --git a/src/util/PreparedQuery.ts b/src/util/prepared-query.ts similarity index 85% rename from src/util/PreparedQuery.ts rename to src/util/prepared-query.ts index c43a9127..9aa3b6cd 100644 --- a/src/util/PreparedQuery.ts +++ b/src/util/prepared-query.ts @@ -6,11 +6,22 @@ import { encode, partiallyEncodeObject, } from "../cbor"; + import { replacer } from "../data/cbor"; let textEncoder: TextEncoder; export type ConvertMethod = (result: unknown[]) => T; + +/** + * A prepared query that encapulates a query and its bindings. + * + * While bound values are safely encoded into the query, you can + * pass a `Gap` instance to later set or override it when sending the query. + * + * Prepared queries can be extended with the `append` method, which + * allows you to add more query segments and bindings. + */ export class PreparedQuery { private _query: Uint8Array; private _bindings: Record; diff --git a/src/util/processAuthVars.ts b/src/util/process-auth-vars.ts similarity index 100% rename from src/util/processAuthVars.ts rename to src/util/process-auth-vars.ts diff --git a/src/util/tagged-template.ts b/src/util/tagged-template.ts index 97f90155..55482cce 100644 --- a/src/util/tagged-template.ts +++ b/src/util/tagged-template.ts @@ -1,5 +1,12 @@ -import { PreparedQuery } from "./PreparedQuery.ts"; +import { PreparedQuery } from "./prepared-query.ts"; +/** + * Create a prepared query from a template string + * + * @param query_raw The template string + * @param values Optional values to bind to the query + * @returns A prepared query + */ export function surrealql( query_raw: string[] | TemplateStringsArray, ...values: unknown[] diff --git a/src/util/versionCheck.ts b/src/util/version-check.ts similarity index 74% rename from src/util/versionCheck.ts rename to src/util/version-check.ts index f0837871..32e360fe 100644 --- a/src/util/versionCheck.ts +++ b/src/util/version-check.ts @@ -1,11 +1,20 @@ import { UnsupportedVersion, VersionRetrievalFailure } from "../errors.ts"; type Version = `${number}.${number}.${number}`; + export const defaultVersionCheckTimeout = 5000; export const supportedSurrealDbVersionMin: Version = "1.4.2"; export const supportedSurrealDbVersionUntil: Version = "3.0.0"; export const supportedSurrealDbVersionRange: string = `>= ${supportedSurrealDbVersionMin} < ${supportedSurrealDbVersionUntil}`; +/** + * Perform a version check against the supported version range + * and throws an error if the version is not supported. + * + * @param version The version to check. + * @param min The minimum supported version. + * @param until The maximum supported version. + */ export function versionCheck( version: string, min: Version = supportedSurrealDbVersionMin, @@ -18,6 +27,14 @@ export function versionCheck( return true; } +/** + * Perform a version check against the supported version range. + * + * @param version The version to check. + * @param min The minimum supported version. + * @param until The maximum supported version. + * @returns True if the version is supported, false otherwise. + */ export function isVersionSupported( version: string, min: Version = supportedSurrealDbVersionMin, @@ -33,6 +50,13 @@ export function isVersionSupported( ); } +/** + * Attempt to retrieve the version of the remote connection URL. + * + * @param url The URL to retrieve the version from. + * @param timeout The timeout in milliseconds. + * @returns The version information + */ export async function retrieveRemoteVersion( url: URL, timeout?: number, diff --git a/tests/integration/surreal.ts b/tests/integration/surreal.ts index 69e43416..1301445d 100644 --- a/tests/integration/surreal.ts +++ b/tests/integration/surreal.ts @@ -1,4 +1,5 @@ import { afterAll } from "bun:test"; +import type { Subprocess } from "bun"; import Surreal, { type AnyAuth } from "../../src"; import { SURREAL_BIND, SURREAL_PORT_UNREACHABLE, SURREAL_USER } from "./env.ts"; import { SURREAL_PASS } from "./env.ts"; @@ -39,30 +40,42 @@ type CreateSurrealOptions = { auth?: PremadeAuth; reachable?: boolean; unselected?: boolean; + reconnect?: boolean; }; -export async function setupServer(): Promise<{ +export async function spawnTestServer(): Promise<{ createSurreal: (options?: CreateSurrealOptions) => Promise; + startServer: () => Promise; + stopServer: () => Promise; }> { - const proc = Bun.spawn(["surreal", "start"], { - env: { - SURREAL_BIND, - SURREAL_USER, - SURREAL_PASS, - }, - }); + let server: Subprocess | undefined; - await Bun.sleep(1000); + async function startServer() { + if (server) return; - afterAll(async () => { - proc.kill(); - }); + server = Bun.spawn(["surreal", "start"], { + env: { + SURREAL_BIND, + SURREAL_USER, + SURREAL_PASS, + }, + }); + + await Bun.sleep(1000); + } + + async function stopServer() { + if (!server) return; + server.kill(); + server = undefined; + } async function createSurreal({ protocol, auth, reachable, unselected, + reconnect, }: CreateSurrealOptions = {}) { protocol = protocol ? protocol : PROTOCOL; const surreal = new Surreal(); @@ -71,11 +84,19 @@ export async function setupServer(): Promise<{ namespace: unselected ? undefined : SURREAL_NS, database: unselected ? undefined : SURREAL_DB, auth: createAuth(auth ?? "root"), + reconnect: reconnect ?? false, + reconnectTimeout: 2500, }); afterAll(async () => await surreal.close()); return surreal; } - return { createSurreal }; + afterAll(async () => { + server?.kill(); + }); + + await startServer(); + + return { createSurreal, startServer, stopServer }; } diff --git a/tests/integration/tests/auth.test.ts b/tests/integration/tests/auth.test.ts index 5c9d11da..679a32b2 100644 --- a/tests/integration/tests/auth.test.ts +++ b/tests/integration/tests/auth.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { RecordId, ResponseError } from "../../../src"; -import { createAuth, setupServer } from "../surreal.ts"; +import { createAuth, spawnTestServer } from "../surreal.ts"; -const { createSurreal } = await setupServer(); +const { createSurreal } = await spawnTestServer(); describe("basic auth", async () => { const surreal = await createSurreal(); diff --git a/tests/integration/tests/connection.test.ts b/tests/integration/tests/connection.test.ts index 899e8717..12306fa1 100644 --- a/tests/integration/tests/connection.test.ts +++ b/tests/integration/tests/connection.test.ts @@ -1,11 +1,12 @@ -import { describe, expect, test } from "bun:test"; import { VersionRetrievalFailure, defaultVersionCheckTimeout, } from "../../../src"; -import { setupServer } from "../surreal.ts"; -const { createSurreal } = await setupServer(); +import { describe, expect, test } from "bun:test"; +import { spawnTestServer } from "../surreal.ts"; + +const { createSurreal } = await spawnTestServer(); describe("version check", async () => { test("check version", async () => { diff --git a/tests/integration/tests/live.test.ts b/tests/integration/tests/live.test.ts index 042096b1..cfbd4b08 100644 --- a/tests/integration/tests/live.test.ts +++ b/tests/integration/tests/live.test.ts @@ -1,15 +1,15 @@ -import { describe, expect, test } from "bun:test"; import { - type LiveAction, type LiveHandlerArguments, RecordId, ResponseError, type Surreal, Uuid, } from "../../../src"; -import { setupServer } from "../surreal.ts"; -const { createSurreal } = await setupServer(); +import { describe, expect, test } from "bun:test"; +import { spawnTestServer } from "../surreal.ts"; + +const { createSurreal } = await spawnTestServer(); const isHttp = (surreal: Surreal) => surreal.connection?.connection.url?.protocol.startsWith("http"); diff --git a/tests/integration/tests/querying.test.ts b/tests/integration/tests/querying.test.ts index 86bc269c..20574d79 100644 --- a/tests/integration/tests/querying.test.ts +++ b/tests/integration/tests/querying.test.ts @@ -1,4 +1,3 @@ -import { describe, expect, test } from "bun:test"; import { Duration, Gap, @@ -11,19 +10,20 @@ import { StringRecordId, Table, Uuid, - decodeCbor, - encodeCbor, surql, } from "../../../src"; + import { BoundExcluded, BoundIncluded, Range, RecordIdRange, } from "../../../src/data/types/range.ts"; -import { setupServer } from "../surreal.ts"; -const { createSurreal } = await setupServer(); +import { describe, expect, test } from "bun:test"; +import { spawnTestServer } from "../surreal.ts"; + +const { createSurreal } = await spawnTestServer(); type Person = { id: RecordId<"person">; diff --git a/tests/integration/tests/reconnect.test.ts b/tests/integration/tests/reconnect.test.ts new file mode 100644 index 00000000..0fa8e5b3 --- /dev/null +++ b/tests/integration/tests/reconnect.test.ts @@ -0,0 +1,23 @@ +import { describe, test } from "bun:test"; +import { spawnTestServer } from "../surreal.ts"; + +const { createSurreal, startServer, stopServer } = await spawnTestServer(); + +describe("reconnect", async () => { + test( + "reconnect", + async () => { + const surreal = await createSurreal({ reconnect: true }); + + // Restarting the server should trigger a reconnect + await stopServer(); + await startServer(); + + // Await automatic connect + await surreal.emitter.subscribeOnce("connected"); + }, + { + timeout: 10_000, + }, + ); +}); diff --git a/tests/unit/convertAuth.test.ts b/tests/unit/convert-auth.test.ts similarity index 100% rename from tests/unit/convertAuth.test.ts rename to tests/unit/convert-auth.test.ts diff --git a/tests/unit/isVersionSupported.test.ts b/tests/unit/is-version-supported.test.ts similarity index 89% rename from tests/unit/isVersionSupported.test.ts rename to tests/unit/is-version-supported.test.ts index 642236cc..695faa8f 100644 --- a/tests/unit/isVersionSupported.test.ts +++ b/tests/unit/is-version-supported.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { isVersionSupported } from "../../src/util/versionCheck.ts"; +import { isVersionSupported } from "../../src/util/version-check.ts"; describe("isVersionSupported", () => { test("1.0.0 should be unsupported", () => {