electric-yjs

SKILL.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.

Weekly Installs
4
GitHub Stars
10.0K
First Seen
11 days ago
Installed on
opencode4
gemini-cli4
claude-code4
github-copilot4
codex4
kimi-cli4