Comprehensive guide for migrating from traditional relational databases (PostgreSQL, MySQL, SQLite) to Lith, including schema translation, data migration, and provenance strategies.
|
Note
|
Status: Documentation Complete, Tooling Planned This document provides complete migration guidance. Migration tooling ( |
Before beginning a migration, understand that Lith is not a drop-in replacement for traditional RDBMS. It represents a fundamental shift in how data is treated:
| Aspect | Traditional RDBMS | Lith |
|---|---|---|
Primary Concern |
Performance, ACID transactions |
Auditability, provenance, reversibility |
Data Model |
Tables with rows |
Documents and edges (graph-document hybrid) |
History |
Optional (audit tables, triggers) |
Built-in (every operation journaled) |
Deletions |
Permanent (unless soft-delete) |
Reversible (tombstones with inverse) |
Mutations |
Who/when via triggers |
Who/why/when required (provenance) |
Schema Evolution |
Manual migrations |
Self-normalizing with proofs |
Query Philosophy |
Declarative (what) |
Narrative (why + what) |
Lith excels for:
-
Investigative Journalism: Complete chain of custody for evidence
-
Compliance/Governance: Built-in audit trails for regulators
-
Agentic AI Systems: Explain and reverse AI decisions
-
Long-Term Archives: Decades-scale data with provenance
-
Research Data: Reproducible data transformations
-
Legal Discovery: Immutable evidence trails
Lith is NOT suitable for:
-
High-Throughput OLTP: >10,000 TPS requirements
-
Analytics/OLAP: Columnar analytics, data warehousing
-
Drop-In Replacement: Apps expecting SQL compatibility
-
Performance-Critical: Sub-millisecond query requirements
-
Ephemeral Data: Cache, sessions, temp data
Before migrating, assess your current system:
| Item | Description | Ready? |
|---|---|---|
Data Volume |
Lith handles datasets up to ~100GB efficiently. Larger datasets require tiered storage. |
☐ |
Transaction Rate |
Maximum ~1,000 TPS per node. Higher requires sharding (🚧 planned). |
☐ |
Query Complexity |
Complex JOINs become edge traversals. Assess query patterns. |
☐ |
Audit Requirements |
Lith provides superior audit trails—verify this is needed. |
☐ |
Schema Stability |
Self-normalizing handles evolution, but frequent changes add overhead. |
☐ |
Application Changes |
Applications need modification for GQL and provenance. |
☐ |
Team Training |
Developers need GQL and provenance concept training. |
☐ |
Catalog your existing database:
-- PostgreSQL: List all tables and row counts
SELECT
schemaname,
tablename,
n_tup_ins - n_tup_del as row_count_estimate
FROM pg_stat_user_tables
ORDER BY row_count_estimate DESC;
-- List foreign key relationships (become edges)
SELECT
tc.table_name as from_table,
kcu.column_name as from_column,
ccu.table_name as to_table,
ccu.column_name as to_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY';-- MySQL: List all tables and row counts
SELECT
table_schema,
table_name,
table_rows
FROM information_schema.tables
WHERE table_schema = 'your_database'
ORDER BY table_rows DESC;
-- List foreign key relationships
SELECT
table_name as from_table,
column_name as from_column,
referenced_table_name as to_table,
referenced_column_name as to_column
FROM information_schema.key_column_usage
WHERE referenced_table_name IS NOT NULL
AND table_schema = 'your_database';Calculate migration complexity:
Score = (Tables × 1) + (Foreign Keys × 2) + (Stored Procedures × 5)
+ (Triggers × 3) + (Views × 2)
Complexity Levels:
0-20: Simple (1-2 weeks)
21-50: Moderate (2-4 weeks)
51-100: Complex (1-2 months)
100+: Enterprise (3+ months, phased approach)| SQL Type | GQL Type | Notes |
|---|---|---|
Numeric Types |
||
|
|
Arbitrary precision in Lith |
|
|
Planned. Use |
|
|
IEEE 754 double precision |
|
(auto-generated) |
Lith uses UUIDs by default |
String Types |
||
|
|
No length limits in Lith |
|
|
Same as VARCHAR |
|
|
Base64 encoded in JSON |
Date/Time Types |
||
|
|
ISO 8601 date |
|
|
ISO 8601 time |
|
|
ISO 8601 with timezone |
|
|
Planned. Use ISO 8601 duration string |
Boolean & JSON |
||
|
|
true/false |
|
|
Native JSON support |
Special Types |
||
|
|
Native UUID support |
|
JSON array |
Use |
|
|
Add CHECK constraint for valid values |
|
|
Planned. Use GeoJSON for now |
Lith-Specific Types |
||
(no equivalent) |
|
0-100 evidence quality rating |
(no equivalent) |
|
Actor + rationale (auto-managed) |
(no equivalent) |
|
Reference to another document |
| SQL Constraint | GQL Equivalent | Example |
|---|---|---|
|
|
|
|
|
|
|
(auto-generated |
Documents have automatic UUIDs |
|
|
|
|
|
|
|
Edge collection |
See relationship migration section |
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT,
author_id INTEGER REFERENCES users(id),
published_at TIMESTAMP,
views INTEGER DEFAULT 0,
status VARCHAR(20) CHECK (status IN ('draft', 'published', 'archived')),
metadata JSONB
);CREATE COLLECTION articles (
title STRING NOT NULL,
content STRING,
published_at TIMESTAMP,
views INTEGER DEFAULT 0,
status STRING DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
metadata JSON
) WITH DESCRIPTION 'News articles with publication workflow';
-- Foreign key becomes an edge collection
CREATE EDGE COLLECTION authored_by
FROM articles TO users
WITH PROPERTIES (
role STRING DEFAULT 'author'
);CREATE TABLE orders (
id SERIAL PRIMARY KEY,
order_number VARCHAR(50) UNIQUE NOT NULL,
customer_id INTEGER NOT NULL REFERENCES customers(id),
shipping_address_id INTEGER REFERENCES addresses(id),
billing_address_id INTEGER REFERENCES addresses(id),
status VARCHAR(30) NOT NULL DEFAULT 'pending',
total_amount DECIMAL(10,2) NOT NULL,
tax_amount DECIMAL(10,2) DEFAULT 0,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
CONSTRAINT valid_status CHECK (
status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')
),
CONSTRAINT positive_amounts CHECK (
total_amount >= 0 AND tax_amount >= 0
)
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(10,2) NOT NULL,
discount_percent DECIMAL(5,2) DEFAULT 0
);-- Main orders collection
CREATE COLLECTION orders (
order_number STRING UNIQUE NOT NULL,
status STRING NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
total_amount FLOAT NOT NULL CHECK (total_amount >= 0),
tax_amount FLOAT DEFAULT 0 CHECK (tax_amount >= 0),
notes STRING,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP
) WITH DESCRIPTION 'Customer orders with status tracking';
-- Customer relationship
CREATE EDGE COLLECTION placed_by
FROM orders TO customers
WITH PROPERTIES (
placed_at TIMESTAMP DEFAULT NOW()
);
-- Address relationships (two edge types for different purposes)
CREATE EDGE COLLECTION ships_to
FROM orders TO addresses
WITH PROPERTIES (
address_type STRING DEFAULT 'shipping'
);
CREATE EDGE COLLECTION bills_to
FROM orders TO addresses
WITH PROPERTIES (
address_type STRING DEFAULT 'billing'
);
-- Order items as separate collection with edges
CREATE COLLECTION order_items (
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price FLOAT NOT NULL,
discount_percent FLOAT DEFAULT 0
) WITH DESCRIPTION 'Individual items within an order';
-- Relationships for order items
CREATE EDGE COLLECTION contains_item
FROM orders TO order_items
WITH PROPERTIES (
line_number INTEGER
);
CREATE EDGE COLLECTION item_is_product
FROM order_items TO products;#!/bin/bash
# export-postgres.sh - Export PostgreSQL database for Lith migration
DATABASE="your_database"
OUTPUT_DIR="./lith-export"
mkdir -p "$OUTPUT_DIR"
# Export each table as JSON (preferred format)
for table in $(psql -d "$DATABASE" -t -c "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"); do
echo "Exporting $table..."
psql -d "$DATABASE" -c "\COPY (SELECT row_to_json(t) FROM (SELECT * FROM $table) t) TO '$OUTPUT_DIR/${table}.jsonl'"
done
# Export schema information
pg_dump --schema-only "$DATABASE" > "$OUTPUT_DIR/schema.sql"
# Export foreign key relationships for edge creation
psql -d "$DATABASE" -t -c "
SELECT json_build_object(
'from_table', tc.table_name,
'from_column', kcu.column_name,
'to_table', ccu.table_name,
'to_column', ccu.column_name,
'constraint_name', tc.constraint_name
)
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
" > "$OUTPUT_DIR/relationships.jsonl"
echo "Export complete. Files in $OUTPUT_DIR"-- Create collections based on exported schema
-- (Run schema translation first)
-- Import documents with default provenance for migration
INSERT INTO articles
IMPORT FROM 'articles.jsonl'
WITH PROVENANCE {
actor: "migration-script",
rationale: "Bulk import from PostgreSQL migration on 2026-01-12"
};
-- Verify import
SELECT COUNT(*) FROM articles;
INTROSPECT COLLECTION articles;| PostgreSQL Feature | Lith Approach |
|---|---|
Schemas (namespaces) |
Prefix collection names: |
Sequences |
Use UUID |
Materialized Views |
Create read-through collections (🚧 planned) |
Stored Procedures |
Move logic to application layer |
Triggers |
Use journal subscriptions (🚧 planned) |
Full-Text Search |
Integration with Meilisearch/Typesense (🚧 planned) |
PostGIS Geometry |
Use GeoJSON in JSON field, native GEO type planned |
Arrays |
Use JSON arrays |
JSONB operators |
Native JSON support with similar operators |
CTEs (WITH clause) |
Chain queries or use TRAVERSE for graph queries |
#!/bin/bash
# export-mysql.sh - Export MySQL database for Lith migration
DATABASE="your_database"
OUTPUT_DIR="./lith-export"
mkdir -p "$OUTPUT_DIR"
# Export each table as JSON
for table in $(mysql -N -e "SHOW TABLES" "$DATABASE"); do
echo "Exporting $table..."
mysql -N -e "SELECT JSON_OBJECT($(mysql -N -e "SELECT GROUP_CONCAT(CONCAT(\"'\", COLUMN_NAME, \"', \", COLUMN_NAME)) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='$DATABASE' AND TABLE_NAME='$table'")) FROM $table" "$DATABASE" > "$OUTPUT_DIR/${table}.jsonl"
done
# Export schema
mysqldump --no-data "$DATABASE" > "$OUTPUT_DIR/schema.sql"
# Export foreign keys
mysql -N -e "
SELECT JSON_OBJECT(
'from_table', TABLE_NAME,
'from_column', COLUMN_NAME,
'to_table', REFERENCED_TABLE_NAME,
'to_column', REFERENCED_COLUMN_NAME,
'constraint_name', CONSTRAINT_NAME
)
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE REFERENCED_TABLE_NAME IS NOT NULL
AND TABLE_SCHEMA = '$DATABASE'
" "$DATABASE" > "$OUTPUT_DIR/relationships.jsonl"
echo "Export complete."| MySQL Feature | Lith Approach |
|---|---|
AUTO_INCREMENT |
Use UUID |
ENUM type |
STRING with CHECK constraint |
SET type |
JSON array with validation |
Spatial types |
GeoJSON in JSON field |
FULLTEXT indexes |
External search integration |
Stored Procedures |
Application layer logic |
Events (scheduled) |
External scheduler (cron, systemd timers) |
Partitioning |
Collection sharding (🚧 planned) |
#!/bin/bash
# export-sqlite.sh - Export SQLite database for Lith migration
DATABASE="your_database.db"
OUTPUT_DIR="./lith-export"
mkdir -p "$OUTPUT_DIR"
# Get list of tables
tables=$(sqlite3 "$DATABASE" "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
# Export each table as JSON
for table in $tables; do
echo "Exporting $table..."
sqlite3 -json "$DATABASE" "SELECT * FROM $table" > "$OUTPUT_DIR/${table}.json"
done
# Export schema
sqlite3 "$DATABASE" ".schema" > "$OUTPUT_DIR/schema.sql"
echo "Export complete."| SQLite Feature | Lith Approach |
|---|---|
ROWID/INTEGER PRIMARY KEY |
Use UUID |
Dynamic typing |
Explicit type declarations in GQL |
ATTACH DATABASE |
Separate Lith databases |
Virtual tables (FTS5) |
External search integration |
JSON1 extension |
Native JSON support |
Window functions |
Query decomposition or application logic |
Foreign keys in RDBMS become edge collections in Lith. This is one of the most significant changes in migration.
-- articles.author_id → users.id
ALTER TABLE articles
ADD CONSTRAINT fk_author
FOREIGN KEY (author_id) REFERENCES users(id);-- Remove author_id from articles collection
-- Create edge collection instead
CREATE EDGE COLLECTION written_by
FROM articles TO users
WITH PROPERTIES (
role STRING DEFAULT 'author'
);#!/bin/bash
# migrate-relationships.sh - Convert foreign keys to edges
# For each relationship in relationships.jsonl:
while IFS= read -r line; do
from_table=$(echo "$line" | jq -r '.from_table')
from_column=$(echo "$line" | jq -r '.from_column')
to_table=$(echo "$line" | jq -r '.to_table')
# Generate edge collection name
edge_name="${from_table}_to_${to_table}"
echo "Creating edge collection: $edge_name"
# Generate GQL for edge creation
cat << EOF
CREATE EDGE COLLECTION $edge_name
FROM $from_table TO $to_table;
-- Migrate existing relationships
INSERT INTO $edge_name
SELECT
a._key as _from,
(SELECT _key FROM $to_table WHERE old_id = a.$from_column) as _to
FROM $from_table a
WHERE a.$from_column IS NOT NULL
WITH PROVENANCE {
actor: "migration-script",
rationale: "Converting foreign key $from_column to edge"
};
EOF
done < relationships.jsonlCREATE TABLE article_tags (
article_id INTEGER REFERENCES articles(id),
tag_id INTEGER REFERENCES tags(id),
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (article_id, tag_id)
);-- Junction table becomes edge collection with properties
CREATE EDGE COLLECTION tagged_with
FROM articles TO tags
WITH PROPERTIES (
added_at TIMESTAMP DEFAULT NOW(),
added_by STRING
);
-- Migrate existing tag relationships
INSERT INTO tagged_with
SELECT
(SELECT _key FROM articles WHERE old_id = jt.article_id) as _from,
(SELECT _key FROM tags WHERE old_id = jt.tag_id) as _to,
jt.added_at as added_at
FROM article_tags_import jt
WITH PROVENANCE {
actor: "migration-script",
rationale: "Converting article_tags junction table"
};CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
manager_id INTEGER REFERENCES employees(id)
);CREATE COLLECTION employees (
name STRING NOT NULL
);
CREATE EDGE COLLECTION reports_to
FROM employees TO employees
WITH PROPERTIES (
relationship STRING DEFAULT 'direct_report'
);
-- Query the hierarchy
TRAVERSE
START employees WHERE name = 'Jane'
FOLLOW reports_to DEPTH 3
DIRECTION INBOUND
RETURN employees.name, DEPTH;| SQL | GQL |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| SQL | GQL |
|---|---|
|
|
|
|
|
|
|
|
The biggest difference: SQL JOINs become graph traversals.
SELECT
a.title,
u.name as author_name
FROM articles a
JOIN users u ON a.author_id = u.id
WHERE a.status = 'published';-- First, ensure edge collection exists
-- Then traverse the relationship
TRAVERSE
START articles WHERE status = 'published'
FOLLOW written_by
RETURN articles.title, users.name as author_name;SELECT
o.order_number,
c.name as customer_name,
p.name as product_name,
oi.quantity
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
WHERE o.status = 'shipped';TRAVERSE
START orders WHERE status = 'shipped'
FOLLOW placed_by, contains_item
FOLLOW item_is_product FROM order_items
RETURN
orders.order_number,
customers.name as customer_name,
products.name as product_name,
order_items.quantity;SELECT * FROM users
WHERE id IN (
SELECT DISTINCT author_id
FROM articles
WHERE status = 'published'
);-- Reverse traversal: find users who have published articles
TRAVERSE
START articles WHERE status = 'published'
FOLLOW written_by
DIRECTION OUTBOUND
RETURN DISTINCT users.*;| SQL | GQL |
|---|---|
INSERT INTO users (name, email)
VALUES ('John', 'john@example.com'); |
INSERT INTO users {
"name": "John",
"email": "john@example.com"
}
WITH PROVENANCE {
actor: "api-user-123",
rationale: "User registration"
}; |
| SQL | GQL |
|---|---|
UPDATE users
SET email = 'new@example.com'
WHERE id = 123; |
UPDATE users
WHERE _key = '123'
SET email = 'new@example.com'
WITH PROVENANCE {
actor: "admin-456",
rationale: "Email change request #789"
}; |
| SQL | GQL |
|---|---|
DELETE FROM users
WHERE id = 123; |
DELETE FROM users
WHERE _key = '123'
WITH PROVENANCE {
actor: "admin-456",
rationale: "GDPR deletion request #101"
}; |
Note: Lith DELETE creates a tombstone with inverse operation, allowing UNDO.
Provenance is mandatory for all mutations in Lith. Plan how to capture actor and rationale.
// Simple insert - no audit context
async function createArticle(title, content, authorId) {
await db.query(
'INSERT INTO articles (title, content, author_id) VALUES ($1, $2, $3)',
[title, content, authorId]
);
}// Insert with provenance context
async function createArticle(title, content, authorId, context) {
await lith.query(`
INSERT INTO articles {
"title": $1,
"content": $2
}
WITH PROVENANCE {
actor: $3,
rationale: $4
}
`, [title, content, context.userId, context.reason]);
// Create edge to author
await lith.query(`
CREATE EDGE written_by
FROM articles/_last TO users/$1
WITH PROVENANCE {
actor: $2,
rationale: "Article authorship"
}
`, [authorId, context.userId]);
}// middleware/provenance.js
function provenanceMiddleware(req, res, next) {
// Extract user from JWT or session
const userId = req.user?.id || 'anonymous';
const sessionId = req.sessionID;
// Attach provenance context to request
req.provenance = {
actor: `user:${userId}@session:${sessionId}`,
// Rationale comes from request or defaults
rationale: req.body._rationale || req.query._rationale ||
`${req.method} ${req.path} from ${req.ip}`
};
next();
}
// Usage in route
app.post('/articles', provenanceMiddleware, async (req, res) => {
const { title, content } = req.body;
await lith.insert('articles', { title, content }, {
actor: req.provenance.actor,
rationale: req.body.rationale || 'Created via API'
});
});For migrated data, use migration-specific provenance:
-- Set migration context for bulk imports
SET PROVENANCE DEFAULTS {
actor: "migration-script-v1.0",
rationale: "Initial data migration from PostgreSQL (production backup 2026-01-10)"
};
-- Bulk imports use default provenance
INSERT INTO articles IMPORT FROM 'articles.jsonl';
INSERT INTO users IMPORT FROM 'users.jsonl';
-- Clear defaults when done
CLEAR PROVENANCE DEFAULTS;| Scenario | Actor Format | Rationale Example |
|---|---|---|
User action |
|
"Updated profile email" |
API integration |
|
"Payment confirmed #PAY-456" |
Scheduled job |
|
"Expired sessions purge" |
Migration |
|
"PostgreSQL migration from backup 2026-01-10" |
Admin override |
|
"Emergency data fix per ticket #INC-789" |
AI agent |
|
"Automated classification confidence=0.95" |
-- Compare row counts
SELECT COUNT(*) as lith_count FROM articles;
-- Compare with: SELECT COUNT(*) FROM articles; (in PostgreSQL)
-- Verify all relationships migrated
SELECT
COUNT(*) as edge_count,
(SELECT COUNT(*) FROM articles WHERE author_id IS NOT NULL) as expected
FROM written_by;
-- Check for data integrity
SELECT * FROM articles
WHERE title IS NULL OR title = '';
-- Verify provenance on migrated data
SELECT _provenance FROM articles LIMIT 5;Create test queries that should return identical results:
// test/migration-validation.js
const testCases = [
{
name: 'Article count by status',
postgres: 'SELECT status, COUNT(*) FROM articles GROUP BY status',
lith: 'SELECT status, COUNT(*) FROM articles GROUP BY status'
},
{
name: 'Users with articles',
postgres: `
SELECT u.name, COUNT(a.id) as article_count
FROM users u
LEFT JOIN articles a ON a.author_id = u.id
GROUP BY u.id
`,
lith: `
TRAVERSE
START users
FOLLOW written_by DIRECTION INBOUND
RETURN users.name, COUNT(articles) as article_count
GROUP BY users._key
`
}
];
async function validateMigration() {
for (const test of testCases) {
const pgResult = await postgres.query(test.postgres);
const lithResult = await lith.query(test.lith);
assert.deepEqual(
normalizeResult(pgResult),
normalizeResult(lithResult),
`Mismatch in: ${test.name}`
);
}
}-- Sample random documents to verify data integrity
SELECT * FROM articles
ORDER BY RANDOM()
LIMIT 10;
-- Check for missing references (should be 0)
SELECT COUNT(*) FROM written_by
WHERE _to NOT IN (SELECT _key FROM users);
-- Verify timestamps preserved
SELECT
MIN(created_at) as earliest,
MAX(created_at) as latest,
COUNT(*) as total
FROM articles;Maintain parallel operation during migration:
// During migration: write to both systems
async function createArticle(data, context) {
// Write to Lith (new system)
const lithResult = await lith.insert('articles', data, context.provenance);
// Write to PostgreSQL (old system) for rollback safety
await postgres.query(
'INSERT INTO articles (id, title, content) VALUES ($1, $2, $3)',
[lithResult._key, data.title, data.content]
);
return lithResult;
}Phase 1: Preparation (1-2 weeks)
├── Schema translation complete
├── Export scripts tested
├── Import scripts tested
├── Application changes coded (feature flagged)
└── Rollback procedures documented
Phase 2: Shadow Mode (1-2 weeks)
├── Dual-write enabled
├── Read from PostgreSQL (source of truth)
├── Write to both PostgreSQL and Lith
├── Compare query results continuously
└── Fix discrepancies
Phase 3: Cutover (1 day)
├── Stop writes to PostgreSQL
├── Final sync to Lith
├── Verify data consistency
├── Switch reads to Lith
├── Enable Lith as source of truth
└── Keep PostgreSQL read-only for rollback
Phase 4: Cleanup (1 week)
├── Monitor Lith in production
├── Remove dual-write code
├── Archive PostgreSQL backup
└── Decommission PostgreSQL (after confidence period)| Tool | Purpose | Status |
|---|---|---|
Export scripts |
Shell scripts for RDBMS export |
Examples in this guide |
Schema translator |
SQL DDL → GQL DDL |
Manual process |
Bulk import |
GQL |
Available |
Validation queries |
Data integrity checks |
Manual queries |
| Tool | Purpose | Timeline |
|---|---|---|
|
CLI migration tool |
🚧 Planned |
PostgreSQL adapter |
Direct pg_dump integration |
🚧 Planned |
MySQL adapter |
Direct mysqldump integration |
🚧 Planned |
SQLite adapter |
Direct SQLite integration |
🚧 Planned |
Schema analyzer |
Automatic SQL→GQL translation |
🚧 Planned |
Relationship detector |
FK to edge auto-conversion |
🚧 Planned |
Validation framework |
Automated migration testing |
🚧 Planned |
# Planned lith-migrate CLI
# Analyze source database
lith-migrate analyze \
--source postgres://user:pass@host/db \
--output analysis.json
# Generate GQL schema
lith-migrate schema \
--from analysis.json \
--output schema.gql
# Export data with transformation
lith-migrate export \
--source postgres://user:pass@host/db \
--format jsonl \
--output ./export/
# Import to Lith
lith-migrate import \
--target lith://localhost:8765/mydb \
--schema schema.gql \
--data ./export/ \
--provenance '{"actor": "migration", "rationale": "Initial import"}'
# Validate migration
lith-migrate validate \
--source postgres://user:pass@host/db \
--target lith://localhost:8765/mydb \
--report validation-report.html| Issue | Cause | Solution |
|---|---|---|
Type mismatch |
PostgreSQL types not mapping cleanly |
Use explicit type conversion in export |
Missing relationships |
Foreign keys not captured |
Re-run relationship export, check junction tables |
Duplicate keys |
ID collision during import |
Use UUID generation, don’t preserve integer IDs |
Provenance errors |
Missing actor/rationale |
Set default provenance for bulk imports |
Slow imports |
Large dataset, no batching |
Use batch imports with progress tracking |
Encoding issues |
Non-UTF8 data in source |
Convert encoding during export |
NULL handling |
Different NULL semantics |
Explicit NULL checks in validation |
-- Find documents without edges (orphaned data)
SELECT * FROM articles
WHERE _key NOT IN (SELECT _from FROM written_by);
-- Find edges pointing to non-existent documents
SELECT * FROM written_by
WHERE _to NOT IN (SELECT _key FROM users);
-- Check for duplicate data
SELECT title, COUNT(*) as count
FROM articles
GROUP BY title
HAVING COUNT(*) > 1;
-- Inspect journal for migration activity
SELECT * FROM _journal
WHERE provenance.actor LIKE 'migration%'
ORDER BY sequence DESC
LIMIT 100;-- Create indexes for common query patterns
CREATE INDEX idx_articles_status ON articles(status);
CREATE INDEX idx_articles_created ON articles(created_at);
-- For edge traversals, ensure edge indexes
CREATE INDEX idx_written_by_from ON written_by(_from);
CREATE INDEX idx_written_by_to ON written_by(_to);
-- Analyze query performance
EXPLAIN SELECT * FROM articles WHERE status = 'published';-
Quickstart Guide - Getting started with Lith
-
GQL Specification - Complete GQL language reference
-
API Reference - Programmatic interfaces
-
Deployment Guide - Production deployment
-
Architecture - Technical architecture