From 38d9ee9d339baf54253e0d70db6d38fb90758587 Mon Sep 17 00:00:00 2001 From: Shon Thomas Date: Sun, 12 Apr 2026 21:56:48 -0800 Subject: [PATCH] feat(connection): accept embedded SurrealDB protocols ConnectionConfig now accepts `protocol: 'mem' | 'rocksdb' | 'surrealkv' | 'surrealkv+versioned'` for in-process connections via the @surrealdb/node (or @surrealdb/wasm) engine packages. Persistent engines take a `path` field; `mem` is ephemeral. Credential-less embedded connections skip signin automatically. Exports `EMBEDDED_PROTOCOLS` + `isEmbeddedProtocol()` type guard. `validateConnectionConfig()` bypasses host/port/username/password checks when an embedded protocol is in use but still validates namespace and database. Remote connections continue to work unchanged. Enables edge/device deployments where each host owns its own SurrealDB instance without a separate server process. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + src/auth/connection.ts | 87 ++++++++++++++++- src/auth/mod.ts | 9 +- src/test/connection.test.ts | 189 +++++++++++++++++++++++++++++++++++- src/utils/validators.ts | 41 ++++++-- 5 files changed, 313 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e4086..6d86578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **type::record() helper** (#6): Added `recordRef(table, id)` function that generates `type::record('table:id')` SurrealQL, usable in SELECT expressions and WHERE conditions. - **SurrealDB function support in field values** (#7): Added `surqlFn(name, ...args)` for server-side function references (e.g. `time::now()`, `math::floor()`) that render as raw SurrealQL in create/update operations instead of being parameterized. Updated `quoteValue()` to detect and pass through `SurrealFnValue` objects. - **Result extraction helpers** (#8): Enhanced `extractScalar()` with optional `key` and `defaultValue` parameters for targeted field extraction and fallback values. +- **Embedded SurrealDB protocols**: `ConnectionConfig` now accepts `protocol: 'mem' | 'rocksdb' | 'surrealkv' | 'surrealkv+versioned'` for in-process connections via the `@surrealdb/node` / `@surrealdb/wasm` engine packages. Persistent engines take an on-disk `path`; `mem` is ephemeral. Credential-less embedded connections skip signin automatically. Exported `EMBEDDED_PROTOCOLS` constant and `isEmbeddedProtocol()` type guard. `validateConnectionConfig()` now bypasses host/port/username/password checks when an embedded protocol is selected. Enables edge/device deployments where each host owns its own SurrealDB instance. ### Fixed diff --git a/src/auth/connection.ts b/src/auth/connection.ts index fd573db..ab72267 100644 --- a/src/auth/connection.ts +++ b/src/auth/connection.ts @@ -49,7 +49,43 @@ export function buildSigninParams(credentials: AuthCredentials): Record { await db.connect(this.endpoint) - await this.performSignin(db) + + // Embedded engines with no credentials supplied don't require a signin; + // the engine comes up with a default root identity in-process. + const embedded = isEmbeddedProtocol(this.config.protocol) + const hasCredentials = !!this.config.username && !!this.config.password + if (!embedded || hasCredentials) { + await this.performSignin(db) + } else { + // No token involved; set a long expiry so downstream checks pass. + this.expiresAt = Date.now() + 24 * 60 * 60 * 1_000 + } + await db.use({ namespace: this.config.namespace, database: this.config.database, @@ -180,18 +237,38 @@ export class SurrealConnectionManager { } /** - * Build a secure endpoint URL + * Build the connection endpoint URL. Remote protocols emit a standard + * `proto://host:port` URL (appending `/rpc` for HTTP). Embedded protocols + * emit `mem://` for in-memory or `://` for persistent + * engines (requires `config.path`). */ private buildSecureEndpoint(config: ConnectionConfig): string { let protocol = config.protocol || 'http' + if (isEmbeddedProtocol(protocol)) { + if (protocol === 'mem') { + return 'mem://' + } + const path = config.path + if (!path) { + throw new Error( + `Embedded protocol '${protocol}' requires a 'path' field on ConnectionConfig`, + ) + } + return `${protocol}://${path}` + } + if (config.useSSL) { protocol = protocol === 'ws' ? 'wss' : 'https' } const allowedProtocols = ['http', 'https', 'ws', 'wss'] if (!allowedProtocols.includes(protocol)) { - throw new Error(`Invalid protocol: ${protocol}. Allowed protocols: ${allowedProtocols.join(', ')}`) + throw new Error( + `Invalid protocol: ${protocol}. Allowed protocols: ${allowedProtocols.join(', ')}, or one of ${ + EMBEDDED_PROTOCOLS.join(', ') + } for embedded engines`, + ) } try { diff --git a/src/auth/mod.ts b/src/auth/mod.ts index 9c2325d..4ff05e3 100644 --- a/src/auth/mod.ts +++ b/src/auth/mod.ts @@ -2,7 +2,14 @@ * SurQL authentication module exports */ -export { buildSigninParams, type ConnectionConfig, SurrealConnectionManager } from './connection.ts' +export { + buildSigninParams, + type ConnectionConfig, + EMBEDDED_PROTOCOLS, + type EmbeddedProtocol, + isEmbeddedProtocol, + SurrealConnectionManager, +} from './connection.ts' export { SIGNIN_FIELDS_BY_TYPE } from './constants.ts' export { diff --git a/src/test/connection.test.ts b/src/test/connection.test.ts index c209448..3f58d14 100644 --- a/src/test/connection.test.ts +++ b/src/test/connection.test.ts @@ -2,7 +2,12 @@ import { Surreal } from 'surrealdb' import { assert, assertEquals, assertRejects } from '@std/assert' import { describe, it } from '@std/testing/bdd' import { stub } from '@std/testing/mock' -import { type ConnectionConfig, SurrealConnectionManager } from '../auth/connection.ts' +import { + type ConnectionConfig, + EMBEDDED_PROTOCOLS, + isEmbeddedProtocol, + SurrealConnectionManager, +} from '../auth/connection.ts' const testConfig: ConnectionConfig = { host: Deno.env.get('SURQL_TEST_HOST') || 'localhost', @@ -289,6 +294,188 @@ describe('SurrealConnectionManager', () => { }) }) + describe('embedded protocol', () => { + it('isEmbeddedProtocol classifies each embedded protocol and rejects remote', () => { + for (const proto of EMBEDDED_PROTOCOLS) { + assertEquals(isEmbeddedProtocol(proto), true) + } + for (const proto of ['http', 'https', 'ws', 'wss', 'tcp', undefined]) { + assertEquals(isEmbeddedProtocol(proto), false) + } + }) + + it('constructs a manager with the in-memory protocol and no host/port', () => { + const config: ConnectionConfig = { + host: '', + port: '', + namespace: 'test', + database: 'test', + username: '', + password: '', + protocol: 'mem', + } + const manager = new SurrealConnectionManager(config) + assert(manager instanceof SurrealConnectionManager) + }) + + it('constructs a manager with surrealkv + path', () => { + const config: ConnectionConfig = { + host: '', + port: '', + namespace: 'test', + database: 'test', + username: '', + password: '', + protocol: 'surrealkv', + path: '/tmp/test.db', + } + const manager = new SurrealConnectionManager(config) + assert(manager instanceof SurrealConnectionManager) + }) + + it('rejects surrealkv without a path', () => { + const config: ConnectionConfig = { + host: '', + port: '', + namespace: 'test', + database: 'test', + username: '', + password: '', + protocol: 'surrealkv', + } + let caught: unknown + try { + new SurrealConnectionManager(config) + } catch (e) { + caught = e + } + assert(caught !== undefined, 'expected construction to throw') + assert( + String(caught).includes("requires a 'path' field"), + `unexpected error: ${String(caught)}`, + ) + }) + + it('rejects rocksdb without a path', () => { + const config: ConnectionConfig = { + host: '', + port: '', + namespace: 'test', + database: 'test', + username: '', + password: '', + protocol: 'rocksdb', + } + let caught: unknown + try { + new SurrealConnectionManager(config) + } catch (e) { + caught = e + } + assert(caught !== undefined, 'expected construction to throw') + assert( + String(caught).includes("requires a 'path' field"), + `unexpected error: ${String(caught)}`, + ) + }) + + it('passes the mem:// endpoint to Surreal.connect() and skips signin with no credentials', async () => { + const receivedEndpoints: Array = [] + const connectStub = stub( + Surreal.prototype, + 'connect', + (endpoint: string | URL) => { + receivedEndpoints.push(endpoint) + return Promise.resolve(true as const) + }, + ) + // deno-lint-ignore no-explicit-any + const signinStub = stub(Surreal.prototype, 'signin' as any, () => { + throw new Error('signin must not be called for credential-less embedded connections') + }) + const useStub = stubUse() + try { + const manager = new SurrealConnectionManager({ + host: '', + port: '', + namespace: 'test', + database: 'test', + username: '', + password: '', + protocol: 'mem', + }) + const db = await manager.getConnection() + assert(db instanceof Surreal) + assertEquals(receivedEndpoints, ['mem://']) + assertEquals(signinStub.calls.length, 0) + } finally { + connectStub.restore() + signinStub.restore() + useStub.restore() + } + }) + + it('passes the surrealkv:// endpoint to Surreal.connect()', async () => { + const receivedEndpoints: Array = [] + const connectStub = stub( + Surreal.prototype, + 'connect', + (endpoint: string | URL) => { + receivedEndpoints.push(endpoint) + return Promise.resolve(true as const) + }, + ) + // deno-lint-ignore no-explicit-any + const signinStub = stub(Surreal.prototype, 'signin' as any, () => { + throw new Error('signin must not be called for credential-less embedded connections') + }) + const useStub = stubUse() + try { + const manager = new SurrealConnectionManager({ + host: '', + port: '', + namespace: 'test', + database: 'test', + username: '', + password: '', + protocol: 'surrealkv', + path: '/var/lib/app.db', + }) + await manager.getConnection() + assertEquals(receivedEndpoints, ['surrealkv:///var/lib/app.db']) + } finally { + connectStub.restore() + signinStub.restore() + useStub.restore() + } + }) + + it('performs signin for an embedded connection when credentials are supplied', async () => { + const connectStub = stub(Surreal.prototype, 'connect', () => Promise.resolve(true as const)) + const signinStub = stubSignin() + const useStub = stubUse() + try { + const manager = new SurrealConnectionManager({ + host: '', + port: '', + namespace: 'test', + database: 'test', + username: 'root', + password: 'root', + protocol: 'surrealkv', + path: '/var/lib/app.db', + }) + await manager.getConnection() + assertEquals(signinStub.calls.length, 1) + assertEquals(signinStub.calls[0].args[0], { username: 'root', password: 'root' }) + } finally { + connectStub.restore() + signinStub.restore() + useStub.restore() + } + }) + }) + describe('error handling', () => { it('should throw SurrealError for connection failures', async () => { const connectStub = stub(Surreal.prototype, 'connect', () => Promise.reject(new Error('Network error'))) diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 1fecd9b..8f6b372 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -249,8 +249,21 @@ export function validateLikePattern(pattern: unknown): ValidationResult { return { success: true } } +const EMBEDDED_PROTOCOLS_SET: ReadonlySet = new Set([ + 'mem', + 'rocksdb', + 'surrealkv', + 'surrealkv+versioned', +]) + /** - * Validate connection configuration + * Validate connection configuration. + * + * Remote configurations require `host`, `port`, `username`, `password`, and + * valid `namespace`/`database`. Embedded configurations (when `protocol` is + * one of `mem`, `rocksdb`, `surrealkv`, `surrealkv+versioned`) skip the + * network/credential checks and require `path` for persistent engines. + * * @param config - The connection configuration to validate * @returns ValidationResult indicating success or failure with error message */ @@ -268,20 +281,34 @@ export function validateConnectionConfig(config: unknown): ValidationResult { database, username, password, + protocol, + path, } = config as Record - const hostResult = validateHost(host) - if (!hostResult.success) return hostResult - - const portResult = validatePort(port) - if (!portResult.success) return portResult - const namespaceResult = validateTableName(namespace) if (!namespaceResult.success) return namespaceResult const databaseResult = validateTableName(database) if (!databaseResult.success) return databaseResult + if (typeof protocol === 'string' && EMBEDDED_PROTOCOLS_SET.has(protocol)) { + if (protocol !== 'mem') { + if (typeof path !== 'string' || path.length === 0) { + return { + success: false, + error: `Embedded protocol '${protocol}' requires a 'path' field`, + } + } + } + return { success: true } + } + + const hostResult = validateHost(host) + if (!hostResult.success) return hostResult + + const portResult = validatePort(port) + if (!portResult.success) return portResult + const usernameResult = validateUsername(username) if (!usernameResult.success) return usernameResult