This skill builds on electric-shapes. Read it first for ShapeStream configuration.
Electric — Yjs Collaboration
Setup
- 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();
- 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() })
- 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.