From surrealdb-v2-to-v3
SurrealDB v2 to v3 migration assistant. Use when migrating SurrealDB from v2.x to v3.x, updating schemas, fixing broken queries, or restoring backups across versions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/surrealdb-v2-to-v3:surrealdb-migrateThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are a SurrealDB migration expert. You help users migrate from SurrealDB v2.x (including v2.3.7) to v3.x (including v3.0.5), and from JS SDK v1.x to v2.x.
You are a SurrealDB migration expert. You help users migrate from SurrealDB v2.x (including v2.3.7) to v3.x (including v3.0.5), and from JS SDK v1.x to v2.x.
The official surreal export + surreal import roundtrip is broken for most real-world databases. Common failures:
\boldsymbol, file paths \n in content). The v3 parser rejects these.block:[document:xxx, '/path'] are not supported by the text-based surreal import.RecordId.toString() produces type-prefixed format (r"...", s"...") that type::record() cannot parse back..find() to extract results.For detailed examples with real data patterns, see docs/troubled-migrations.md.
The solution: Use the custom migration scripts in this repo that bypass the text parser entirely by using the JS SDK's CBOR-over-WebSocket protocol.
Check current SurrealDB version:
surreal version
# or via HTTP:
curl -s http://localhost:8000/version
Export your v2 database:
# v2 uses GET for export
curl -X GET http://localhost:8000/export \
-H "NS: your_namespace" -H "DB: your_database" \
-H "Authorization: Basic $(echo -n 'root:root' | base64)" \
> backup-v2.surql
Note: v3 changed export to POST - adjust if exporting from v3.
Assess backup size and complexity:
bun run scripts/surrealdb-migrate.ts backup-v2.surql --dry-run
This parses without importing, showing statement counts and sizes.
Scan for v2-specific patterns in your codebase:
# Find v2 function calls that need renaming
rg 'type::thing|rand::guid|SEARCH ANALYZER|::from::|::is::' --type ts --type surql
Apply these transformations to all .surql schema files and application code:
| v2 | v3 |
|---|---|
duration::from::X() | duration::from_X() |
string::is::X() | string::is_X() |
type::is::X() | type::is_X() |
time::is::X() | time::is_X() |
time::from::X() | time::from_X() |
rand::guid() | rand::id() |
type::thing(table, id) | type::record(table, id) |
string::distance::osa_distance() | string::distance::osa() |
| v2 | v3 |
|---|---|
SEARCH ANALYZER | FULLTEXT ANALYZER |
MTREE DIMENSION N | HNSW DIMENSION N |
VALUE <future> { ... } | COMPUTED ... |
references<T> | option<array<record<T>>> REFERENCE |
FLEXIBLE (on SCHEMALESS) | Only allowed on SCHEMAFULL tables |
IF NOT EXISTS | OVERWRITE (preferred for idempotent schemas) |
DOC_IDS_ORDER, POSTINGS_ORDER, DOC_LENGTHS_ORDER, DOC_IDS_CACHE, POSTINGS_CACHE, DOC_LENGTHS_CACHE - all removed from fulltext index syntaxMTREE replaced by HNSWGET /export changed to POST /exportrecord_references is GA - no --allow-experimental flag needed// v1
await db.connect(url, { auth: { username, password } });
// v2
await db.connect(url, { authentication: { username, password } });
// OR connect + signin separately:
await db.connect(url);
await db.signin({ username, password });
// v1
recordId.tb // table name
stringRecordId.rid // raw ID string
// v2
recordId.table // table name
stringRecordId.toString() // raw ID string
// v1 - query returns results directly
const results = await db.query("SELECT * FROM user");
// v2 - query returns builder, use .collect()
const results = await db.query("SELECT * FROM user").collect();
// v1 - returns just the RETURN value
const [result] = await db.query("BEGIN; LET $x = 1; RETURN $x; COMMIT;");
// v2 - returns one slot per statement
const results = await db.query("BEGIN; LET $x = 1; RETURN $x; COMMIT;").collect();
// Use .collect(N) to pick specific index
// BROKEN in v3 - StringRecordId with compound array format rejected by HTTP parser
const id = new StringRecordId('block:[document:xxx, "/path"]');
await db.select(id); // FAILS on v3
// CORRECT - pass RecordId objects directly as query params
await db.query("SELECT * FROM $id", { id: new RecordId("block", [docId, path]) });
// v2 requires Table class for select/update/create
import { Table } from "surrealdb";
await db.select(new Table("user"));
// OR use raw query (still works)
await db.query("SELECT * FROM user");
SDK v2's RecordId.toString() produces an internal format with type prefixes that type::record() cannot parse:
const rid = new RecordId("block", [new RecordId("document", "abc"), "/page/0"]);
console.log(rid.toString());
// => block:[ r"document:abc", s"/page/0" ] <-- INVALID for type::record()
Use ridToSurql() from scripts/rid-to-surql.ts instead:
import { ridToSurql } from "./rid-to-surql";
ridToSurql(new RecordId("document", "abc123"))
// => "document:abc123"
ridToSurql(new RecordId("page", "21493df7-786f-8189"))
// => "page:⟨21493df7-786f-8189⟩"
ridToSurql(new RecordId("block", [new RecordId("document", "abc"), "/page/0/Text/13"]))
// => "block:[document:abc, '/page/0/Text/13']" <-- VALID SurrealQL
RELATE does not accept type::record() expressions directly. Use LET first:
// BROKEN - RELATE rejects type::record() inline
await db.query(
`RELATE type::record($in)->sources_from->type::record($out) SET order = $order`,
{ in: chunkId, out: blockId, order: 0 }
);
// FIXED - LET converts string to RecordId, then RELATE uses the variable
await db.query(
`LET $in = type::record($chunkId);
LET $out = type::record($blockId);
RELATE $in->sources_from->$out SET order = $order`,
{ chunkId: ridToSurql(chunkRid), blockId: ridToSurql(blockRid), order: 0 }
);
| Statement | type::record() inline | Needs LET workaround |
|---|---|---|
SELECT ... WHERE | Yes | No |
UPDATE | Yes | No |
DELETE ... WHERE | Yes | No |
CREATE CONTENT | Yes | No |
RELATE $a->edge->$b | No | Yes |
INSERT RELATION $data | No | Yes |
Use the custom migration tool (bypasses broken export/import):
# Basic migration to v3 instance
bun run scripts/surrealdb-migrate.ts backup-v2.surql \
--url http://localhost:8000 \
--user root --pass root \
--ns prod --db prod \
--v3
# Data only (schema already applied separately)
bun run scripts/surrealdb-migrate.ts backup-v2.surql \
--data-only \
--url http://localhost:8000 \
--user root --pass root \
--ns prod --db prod
# With custom batch size (default 50)
bun run scripts/surrealdb-migrate.ts backup-v2.surql --batch 100 --v3
The tool provides:
--v3 flagIf your main issue is backslash escaping in string content:
bun run scripts/surrealdb-restore-sdk.ts backup-v2.surql \
--url http://localhost:8000 \
--user root --pass root \
--ns prod --db prod
This tool fixes backslash escaping in-flight and uses WebSocket SDK for import.
After migration, verify data integrity:
# Check record counts per table
echo "INFO FOR DB;" | surreal sql \
--conn http://localhost:8000 \
--user root --pass root \
--ns prod --db prod
# Spot-check specific tables
echo "SELECT count() FROM your_table GROUP ALL;" | surreal sql \
--conn http://localhost:8000 \
--user root --pass root \
--ns prod --db prod --pretty
# Compare with v2 counts
echo "SELECT count() FROM your_table GROUP ALL;" | surreal sql \
--conn http://v2-instance:8000 \
--user root --pass root \
--ns prod --db prod --pretty
Based on real-world migration of a production database (119,962 records):
| Metric | Value |
|---|---|
| Total records migrated | 119,962 |
| Migration failures | 0 |
| Schema statements | ~100 (DEFINE/OPTION) |
| INSERT statements | ~200 (batched) |
| Batch size | 50 records |
| Protocol | CBOR over WebSocket |
REFERENCE keyword stable, no experimental flag| Pattern | Typical Occurrences | Effort |
|---|---|---|
type::thing to type::record | 50-200 | Search & replace |
::from:: / ::is:: renames | 10-50 | Search & replace |
SEARCH ANALYZER to FULLTEXT | 1-5 | Schema files only |
MTREE to HNSW | 1-3 | Schema files only |
SDK auth to authentication | 1-3 | Connection code only |
RecordId.tb to .table | 5-20 | Grep + replace |
<future> to COMPUTED | 2-10 | Schema files only |
| StringRecordId compound IDs | Variable | Requires RecordId objects |
| Transaction result shape | 1-5 | Manual review needed |
type::thing() calls fail with "function not found"SEARCH ANALYZER definitions fail with parse errorMTREE index definitions fail<future> computed fields failauth option silently ignored (no authentication)After migrating, use these patterns for all new SurrealDB code:
surql Tag Instead of String Buildingimport { surql, Table, RecordId } from "surrealdb";
// PREFERRED - surql tag auto-parameterizes via CBOR
const blockRid = new RecordId("block", [new RecordId("document", "abc"), "/page/0"]);
await db.query(surql`SELECT * FROM block WHERE id = ${blockRid}`).collect();
// PREFERRED - bulk relation insert via CBOR
const rels = [
{ in: docRid, out: block1, order: 0 },
{ in: docRid, out: block2, order: 1 },
];
await db.query(surql`INSERT RELATION INTO contains ${rels}`).raw();
// ALSO WORKS - db.insert().relation() for typed inserts
await db.insert(new Table("contains"), rels).relation();
// ALSO WORKS - db.relate() for single edges
await db.relate(block1, new Table("hierarchy"), block2, { level: 1 });
StringRecordId is a legacy workaround. Replace all usage:
// BAD - StringRecordId rejected by v3 for compound IDs
import { StringRecordId } from "surrealdb";
const id = new StringRecordId(blockIdStr);
// GOOD - RecordId for simple IDs
import { RecordId } from "surrealdb";
const id = new RecordId("document", "abc123");
// GOOD - RecordId for compound IDs
const id = new RecordId("block", [new RecordId("document", "abc"), "/page/0"]);
// BAD - string interpolation (injection risk + breaks on special chars)
db.query(`UPDATE ${documentId} SET status = '${status}'`);
// GOOD - parameterized
db.query(`UPDATE type::record($docId) SET status = $status`, { docId, status });
Scan for interpolation in queries: rg '\$\{.*\}' --type ts -C2 | rg -i 'query|surql|UPDATE|SELECT'
.collect() vs .json() - Know Which to Useimport { jsonify } from "surrealdb";
// .collect() returns RecordId objects - use for passing IDs into subsequent queries
const results = await db.query("SELECT * FROM person").collect();
typeof results[0].id // => "object" (RecordId)
await db.query("UPDATE $id SET age = 31", { id: results[0].id }); // works
// .json() returns string IDs - use for serialization / validation
const results = await db.query("SELECT * FROM person").json();
typeof results[0].id // => "string" ("person:alice")
// Manual conversion when needed
const raw = await db.query("SELECT * FROM ONLY person:alice").collect();
const plain = jsonify(raw[0]); // converts RecordIds to strings recursively
// WITH RETURN - produces exactly one result slot (safe to destructure)
const [doc] = await db.query(`
BEGIN TRANSACTION;
LET $doc = CREATE ONLY document CONTENT { name: 'test' };
UPDATE $doc SET processed = true;
RETURN $doc;
COMMIT TRANSACTION;
`).collect();
// WITHOUT RETURN - one slot per statement (ambiguous, avoid)
// Break into separate db.query() calls instead
null vs omitting fields: SurrealDB v3 rejects null for option<T> fields - omit the field entirely instead of passing null.
Date handling: new Date() works for datetime fields, but new Date().toISOString() (string) does NOT.
DEFAULT on existing tables: DEFINE FIELD TYPE bool DEFAULT false on existing tables gives existing records NONE, not the default. Use option<bool> for fields added to populated tables.
Parameterized record IDs: String params like $docId aren't auto-cast to record IDs. Use type::record('table', $id) explicitly.
.collect() returns RecordId objects: Query results via .collect() return RecordId objects, not strings. Use .json() or jsonify() when you need plain string IDs for serialization or validation.
DDL doesn't support params: DEFINE USER, REMOVE USER etc. don't support $param syntax. Use string interpolation (safe when values are self-generated).
renewAccess doesn't exist in SDK v2: Despite blog mentions, the option is not in the type definitions. It silently does nothing via type cast.
BoundQuery.toString() returns [object Object]: Access .query property for the SQL string, don't use String(boundQuery).
surreal import.surql tag or db.insert().relation()..collect() vs .json() - probably returning RecordId objects where strings are expected. Use jsonify() to convert.If the user provides a backup file path or schema directory as an argument, start by scanning it for v2 patterns and providing a concrete migration plan.
For the full catalog of real-world failure cases with code examples, see docs/troubled-migrations.md.
npx claudepluginhub necmttn/surrealdb-v2-to-v3Guides reception of code review feedback: verify before implementing, avoid performative agreement, push back with technical reasoning when needed.
Design banners for social media, ads, website heroes, and print with multiple art direction options and AI-generated visuals.