| name | sp00ky-core |
| description | Core client for the Sp00ky reactive local-first SurrealDB framework. Use when initializing Sp00kyClient, registering queries and subscriptions, performing mutations (create/update/delete), handling auth (signUp/signIn/signOut), working with file buckets, or using backend runs (HTTP outbox pattern). |
| metadata | {"author":"sp00ky-sync","version":"0.0.1"} |
Sp00ky Core
@spooky-sync/core provides Sp00kyClient, the main entry point for the Sp00ky framework. It handles local-first data sync with SurrealDB, reactive queries, optimistic mutations, authentication, file storage, and backend runs.
Initialization
import { Sp00kyClient } from '@spooky-sync/core';
import { schema } from './generated/schema';
import schemaSurql from './generated/schema.surql?raw';
const client = new Sp00kyClient({
database: {
endpoint: 'ws://localhost:8000',
namespace: 'my_ns',
database: 'my_db',
store: 'indexeddb',
},
schema,
schemaSurql,
logLevel: 'info',
});
await client.init();
await client.close();
Sp00kyConfig Options
See references/config.md for all configuration options.
Key fields:
database.endpoint — SurrealDB WebSocket URL
database.namespace / database.database — SurrealDB namespace and database
database.store — 'indexeddb' (persistent) or 'memory' (transient)
schema — The SchemaStructure object (use as const satisfies SchemaStructure)
schemaSurql — The compiled SURQL schema string for local DB provisioning
logLevel — 'debug' | 'info' | 'warn' | 'error'
persistenceClient — 'surrealdb' (default), 'localstorage', or a custom PersistenceClient
streamDebounceTime — Debounce ms for stream updates (default: 100)
Querying
Queries are registered with the client and return a hash. You subscribe to the hash to receive reactive updates.
const finalQuery = client.query('post', {})
.where({ author: 'user:alice' })
.orderBy('createdAt', 'desc')
.limit(20)
.build();
const { hash } = await finalQuery.run();
const unsubscribe = await client.subscribe(hash, (records) => {
console.log('Posts:', records);
}, { immediate: true });
unsubscribe();
Query TTL
Queries have a time-to-live. Supported values: '1m', '5m', '10m' (default), '15m', '20m', '25m', '30m', '1h'–'12h', '1d'.
Mutations
Mutations are optimistic and automatically synced to the remote SurrealDB instance.
await client.create('post:abc123', {
title: 'Hello World',
body: 'My first post',
author: 'user:alice',
});
await client.update('post', 'post:abc123', { title: 'Updated Title' });
await client.update('post', 'post:abc123', { body: 'typing...' }, {
debounced: { key: 'recordId_x_fields', delay: 300 },
});
await client.delete('post', 'post:abc123');
Debounced Updates
Use debounced option for frequent updates (e.g., live typing):
key: 'recordId' — Debounce by record ID only (latest write wins, no merge)
key: 'recordId_x_fields' — Debounce by record ID + changed fields (recommended)
Authentication
See references/auth.md for the full auth API.
await client.auth.signUp('user_access', {
email: 'alice@example.com',
password: 'secret',
name: 'Alice',
});
await client.auth.signIn('user_access', {
email: 'alice@example.com',
password: 'secret',
});
const unsub = client.auth.subscribe((userId) => {
console.log('Auth state:', userId);
});
await client.auth.signOut();
Auth state properties: client.auth.token, client.auth.currentUser, client.auth.isAuthenticated, client.auth.isLoading.
Buckets (File Storage)
const avatars = client.bucket('avatars');
await avatars.put('alice/profile.png', fileBytes);
const content = await avatars.get('alice/profile.png');
const exists = await avatars.exists('alice/profile.png');
await avatars.delete('alice/profile.png');
await avatars.copy('alice/profile.png', 'alice/backup.png');
await avatars.rename('old.png', 'new.png');
const files = await avatars.list('alice/');
Buckets must be defined in the schema under the buckets array.
Backend Runs (HTTP Outbox)
Backend runs let you trigger server-side HTTP operations via an outbox pattern. When you call db.run(), it creates a job record in a local outbox table. That record syncs to remote SurrealDB, where the Sp00ky Sync Platform (SSP) picks it up and calls your backend HTTP API. The result status is written back to the job record and syncs to the client reactively.
db.run() Signature
db.run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
backend: B,
route: R,
payload: RoutePayload<S, B, R>,
options?: RunOptions
): Promise<void>
All parameters are type-safe — backend, route, and payload are inferred from the generated schema.
RunOptions
| Option | Type | Default | Description |
|---|
assignedTo | string | undefined | Record ID to link the job to an entity (e.g., 'thread:abc') |
max_retries | number | 3 | Maximum retry attempts on failure |
retry_strategy | 'linear' | 'exponential' | 'linear' | Backoff strategy between retries |
Entity Linking via assignedTo
The assignedTo option is how you link a job to an entity. Passing a record ID:
- Sets the
assigned_to field on the job record (typed as record<thread> etc. in the outbox schema)
- Enables permission scoping — e.g.,
WHERE assigned_to.author.id = $auth.id restricts job visibility to the entity's owner
- Allows querying jobs via relationships — use
.related('jobs') on the parent entity to reactively track job status
Full Example
1. Configure the backend in sp00ky.yml:
backends:
api:
type: http
spec: ../api/openapi.yml
baseUrl: http://host.docker.internal:3660
auth:
type: token
token: MY_SECRET
method:
type: outbox
table: job
schema: ./src/outbox/api.surql
2. Define the outbox table in SurrealQL (src/outbox/api.surql):
DEFINE TABLE job SCHEMAFULL
PERMISSIONS
FOR select, create, update, delete
WHERE $access = "account" AND assigned_to.author.id = $auth.id;
DEFINE FIELD assigned_to ON TABLE job TYPE record<thread>;
DEFINE FIELD path ON TABLE job TYPE string;
DEFINE FIELD payload ON TABLE job TYPE any;
DEFINE FIELD retries ON TABLE job TYPE int DEFAULT ALWAYS 0;
DEFINE FIELD max_retries ON TABLE job TYPE int DEFAULT ALWAYS 3;
DEFINE FIELD retry_strategy ON TABLE job TYPE string DEFAULT ALWAYS "linear"
ASSERT $value IN ["linear", "exponential"];
DEFINE FIELD status ON TABLE job TYPE string DEFAULT ALWAYS "pending"
ASSERT $value IN ["pending", "processing", "success", "failed"];
DEFINE FIELD errors ON TABLE job TYPE array<object> DEFAULT ALWAYS [];
DEFINE FIELD created_at ON TABLE job TYPE datetime VALUE time::now();
3. Run spooky generate — this produces the typed backends block in schema.gen.ts.
4. Call db.run() with entity linking:
await client.run('api', '/spookify', { id: threadId }, {
assignedTo: threadId,
});
How It Works Under the Hood
When you call db.run('api', '/spookify', payload, options):
- Validates the route exists in the schema and all required args are present
- Creates a record in the outbox table (
job) with fields: path, payload (JSON-stringified), max_retries, retry_strategy, and optionally assigned_to
- The record is created via the standard
client.create() path — optimistic, synced to remote
- SSP detects the new job, calls your backend HTTP API, and updates the job's
status field (pending → processing → success/failed)
Common Pitfalls
- Must call
init() before using the client — All operations will fail if you skip initialization.
- Schema is generated — Use
spooky generate to produce the schema TypeScript file and SURQL file from your .surql definitions.
- RecordId format — SurrealDB uses
table:id format. When creating records, pass the full ID (e.g., 'post:abc123'). The framework auto-converts string IDs to RecordId objects internally.
- Queries are hash-based — The same query always produces the same hash, enabling deduplication and caching.