> electric-yjs
Set up ElectricProvider for real-time collaborative editing with Yjs via Electric shapes. Covers ElectricProvider configuration, document updates shape with BYTEA parser (parseToDecoder), awareness shape at offset='now', LocalStorageResumeStateProvider for reconnection with stableStateVector diff, debounceMs for batching writes, sendUrl PUT endpoint, required Postgres schema (ydoc_update and ydoc_awareness tables), CORS header exposure, and sendErrorRetryHandler. Load when implementing collabora
curl "https://skillshub.wtf/electric-sql/electric/electric-yjs?format=md"This skill builds on electric-shapes. Read it first for ShapeStream configuration.
Electric — Yjs Collaboration
Setup
1. Create Postgres tables
CREATE TABLE ydoc_update (
id SERIAL PRIMARY KEY,
room TEXT NOT NULL,
update BYTEA NOT NULL
);
CREATE TABLE ydoc_awareness (
client_id TEXT,
room TEXT,
update BYTEA NOT NULL,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (client_id, room)
);
-- Garbage collect stale awareness entries
CREATE OR REPLACE FUNCTION gc_awareness_timeouts()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM ydoc_awareness
WHERE updated_at < (CURRENT_TIMESTAMP - INTERVAL '30 seconds')
AND room = NEW.room;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER gc_awareness
AFTER INSERT OR UPDATE ON ydoc_awareness
FOR EACH ROW EXECUTE FUNCTION gc_awareness_timeouts();
2. Create server endpoint for receiving updates
// PUT /api/yjs/update — receives binary Yjs update
app.put('/api/yjs/update', async (req, res) => {
const body = Buffer.from(await req.arrayBuffer())
await db.query('INSERT INTO ydoc_update (room, update) VALUES ($1, $2)', [
req.headers['x-room-id'],
body,
])
res.status(200).end()
})
3. Configure ElectricProvider
import * as Y from 'yjs'
import {
ElectricProvider,
LocalStorageResumeStateProvider,
parseToDecoder,
} from '@electric-sql/y-electric'
const ydoc = new Y.Doc()
const roomId = 'my-document'
const resumeProvider = new LocalStorageResumeStateProvider(roomId)
const provider = new ElectricProvider({
doc: ydoc,
documentUpdates: {
shape: {
url: `/api/yjs/doc-shape?room=${roomId}`,
parser: parseToDecoder,
},
sendUrl: '/api/yjs/update',
getUpdateFromRow: (row) => row.update,
},
awarenessUpdates: {
shape: {
url: `/api/yjs/awareness-shape?room=${roomId}`,
parser: parseToDecoder,
offset: 'now', // Only live awareness, no historical backfill
},
sendUrl: '/api/yjs/awareness',
protocol: provider.awareness,
getUpdateFromRow: (row) => row.update,
},
resumeState: resumeProvider.load(),
debounceMs: 100, // Batch rapid edits
})
// Persist resume state for efficient reconnection
resumeProvider.subscribeToResumeState(provider)
Core Patterns
CORS headers for Yjs proxy
// Proxy must expose Electric headers
const corsHeaders = {
'Access-Control-Expose-Headers':
'electric-offset, electric-handle, electric-schema, electric-cursor',
}
Resume state for reconnection
// On construction, pass stored resume state
const provider = new ElectricProvider({
doc: ydoc,
documentUpdates: { shape: shapeOpts, sendUrl: '/api/yjs/update' },
resumeState: resumeProvider.load(),
})
// Subscribe to persist updates
const unsub = resumeProvider.subscribeToResumeState(provider)
// Clean up
provider.destroy()
unsub()
When stableStateVector is provided in resume state, the provider sends only the diff between the stored vector and current doc state on reconnect.
Connection lifecycle
provider.on('status', ({ status }) => {
// 'connecting' | 'connected' | 'disconnected'
console.log('Yjs sync status:', status)
})
provider.on('sync', (synced: boolean) => {
console.log('Document synced:', synced)
})
// Manual disconnect/reconnect
provider.disconnect()
provider.connect()
Common Mistakes
HIGH Not persisting resume state for reconnection
Wrong:
const provider = new ElectricProvider({
doc: ydoc,
documentUpdates: {
shape: { url: '/api/yjs/doc-shape', parser: parseToDecoder },
sendUrl: '/api/yjs/update',
getUpdateFromRow: (row) => row.update,
},
})
Correct:
const resumeProvider = new LocalStorageResumeStateProvider('my-doc')
const provider = new ElectricProvider({
doc: ydoc,
documentUpdates: {
shape: { url: '/api/yjs/doc-shape', parser: parseToDecoder },
sendUrl: '/api/yjs/update',
getUpdateFromRow: (row) => row.update,
},
resumeState: resumeProvider.load(),
})
resumeProvider.subscribeToResumeState(provider)
Without resumeState, the provider fetches the ENTIRE document shape on every reconnect. With stableStateVector, only a diff is sent.
Source: packages/y-electric/src/types.ts:102-112
HIGH Missing BYTEA parser for shape streams
Wrong:
documentUpdates: {
shape: { url: '/api/yjs/doc-shape' },
sendUrl: '/api/yjs/update',
getUpdateFromRow: (row) => row.update,
}
Correct:
import { parseToDecoder } from '@electric-sql/y-electric'
documentUpdates: {
shape: {
url: '/api/yjs/doc-shape',
parser: parseToDecoder,
},
sendUrl: '/api/yjs/update',
getUpdateFromRow: (row) => row.update,
}
Yjs updates are stored as BYTEA in Postgres. Without parseToDecoder, the shape returns raw hex strings instead of lib0 Decoders, and Y.applyUpdate fails silently or corrupts the document.
Source: packages/y-electric/src/utils.ts
MEDIUM Not setting debounceMs for collaborative editing
Wrong:
const provider = new ElectricProvider({
doc: ydoc,
documentUpdates: { shape: shapeOpts, sendUrl: '/api/yjs/update' },
// Default debounceMs = 0: every keystroke sends a PUT
})
Correct:
const provider = new ElectricProvider({
doc: ydoc,
documentUpdates: { shape: shapeOpts, sendUrl: '/api/yjs/update' },
debounceMs: 100,
})
Default debounceMs is 0, sending a PUT request for every keystroke. Set to 100+ to batch rapid edits and reduce server load.
Source: packages/y-electric/src/y-electric.ts
See also: electric-shapes/SKILL.md — Shape configuration and parser setup.
Version
Targets @electric-sql/y-electric v0.1.x.
> related_skills --same-repo
> blog-planner
Interactive blog post authoring. Produces a draft blog post file with structured outline, inline guidance comments, and meta briefs that the author proses up in place. Supports pyramid principle, best sales deck, and release post formats.
> electric-shapes
Configure ShapeStream and Shape to sync a Postgres table to the client. Covers ShapeStreamOptions (url, table, where, columns, replica, offset, handle), custom type parsers (timestamptz, jsonb, int8), column mappers (snakeCamelMapper, createColumnMapper), onError retry semantics, backoff options, log modes (full, changes_only), requestSnapshot, fetchSnapshot, subscribe/unsubscribe, and Shape materialized view. Load when setting up sync, configuring shapes, parsing types, or handling sync errors.
> electric-schema-shapes
Design Postgres schema and Electric shape definitions together for a new feature. Covers single-table shape constraint, cross-table joins using multiple shapes, WHERE clause design for tenant isolation, column selection for bandwidth optimization, replica mode choice (default vs full for old_value), enum casting in WHERE clauses, and txid handshake setup with pg_current_xact_id() for optimistic writes. Load when designing database tables for use with Electric shapes.
> electric-proxy-auth
Set up a server-side proxy to forward Electric shape requests securely. Covers ELECTRIC_PROTOCOL_QUERY_PARAMS forwarding, server-side shape definition (table, where, params), content-encoding/content-length header cleanup, CORS configuration for electric-offset/electric-handle/ electric-schema/electric-cursor headers, auth token injection, ELECTRIC_SECRET/SOURCE_SECRET server-side only, tenant isolation via WHERE positional params, onError 401 token refresh, and subset security (AND semantics).