Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 82 additions & 5 deletions src/auth/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,43 @@ export function buildSigninParams(credentials: AuthCredentials): Record<string,
}

/**
* Configuration for SurrealDB connection
* Embedded protocols supported by the surrealdb JS SDK via its optional
* engine packages (@surrealdb/node for server-side, @surrealdb/wasm for
* browsers). When one of these protocols is used the connection is served
* in-process instead of over the network.
*
* - `mem`: in-memory, ephemeral (requires no `path`)
* - `rocksdb`, `surrealkv`, `surrealkv+versioned`: persistent on-disk
* (require a `path`)
*/
export const EMBEDDED_PROTOCOLS = [
'mem',
'rocksdb',
'surrealkv',
'surrealkv+versioned',
] as const
export type EmbeddedProtocol = (typeof EMBEDDED_PROTOCOLS)[number]

/**
* Returns true if the given protocol is an embedded (in-process) protocol.
*/
export function isEmbeddedProtocol(p: string | undefined): p is EmbeddedProtocol {
if (p === undefined) return false
return (EMBEDDED_PROTOCOLS as readonly string[]).includes(p)
}

/**
* Configuration for SurrealDB connection.
*
* Remote (default): populate `host`, `port`, `username`, `password`, and an
* optional remote `protocol` (`http`/`https`/`ws`/`wss`).
*
* Embedded: set `protocol` to one of the {@link EmbeddedProtocol} values. For
* persistent engines (`rocksdb`, `surrealkv`, `surrealkv+versioned`) also set
* `path` to the on-disk location. `host`/`port`/`username`/`password` are
* unused in embedded mode and may be empty strings or any placeholder.
*
* Namespace and database are always required.
*/
export interface ConnectionConfig {
host: string
Expand All @@ -59,7 +95,17 @@ export interface ConnectionConfig {
username: string
password: string
useSSL?: boolean
protocol?: 'http' | 'https' | 'ws' | 'wss'
protocol?:
| 'http'
| 'https'
| 'ws'
| 'wss'
| EmbeddedProtocol
/**
* Filesystem path for persistent embedded engines. Required when `protocol`
* is `rocksdb`, `surrealkv`, or `surrealkv+versioned`. Ignored otherwise.
*/
path?: string
}

/**
Expand Down Expand Up @@ -137,7 +183,18 @@ export class SurrealConnectionManager {
*/
private async performConnection(db: Surreal): Promise<Surreal> {
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,
Expand Down Expand Up @@ -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 `<protocol>://<path>` 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 {
Expand Down
9 changes: 8 additions & 1 deletion src/auth/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
189 changes: 188 additions & 1 deletion src/test/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string | URL> = []
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://<path> endpoint to Surreal.connect()', async () => {
const receivedEndpoints: Array<string | URL> = []
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')))
Expand Down
Loading
Loading