Skip to content

Recipe: Collaborative spreadsheet

A simplified Airtable / Google Sheets clone backed by TableCrdtEngine. Concurrent users add and remove rows, edit cells, and reorder columns without locks.

Domain model

// Each cell stores a typed value
public abstract record CellValue;
public record TextValue(string Text) : CellValue;
public record NumberValue(decimal N)  : CellValue;
public record BoolValue(bool B)       : CellValue;

// Column definition — name, type, validators
public record ColumnDef(string Name, string Type, int? MaxLength = null);

Server

using OpStream.Server.Engine.Table;

builder.Services
    .AddOpStream()
    .UsePostgreSql(builder.Configuration.GetConnectionString("OpStream")!)
    .UseAuthorization<MyAuthorizer>()
    .AddEngine<TableDocument, TableOpBatch, TableCrdtEngine>("sheet")
    .AddSignalRTransport();

Client helpers

using OpStream.Server.Engine.Common;
using OpStream.Server.Engine.Table;

readonly TableCrdtEngine<CellValue> typed = new();

long NextTs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

string PositionAfter(string? prev, string? next) => FractionalIndex.Between(prev, next);

Inserting a row at the end

var lastRow = state.Rows.Values
    .Where(r => !r.IsDeleted)
    .OrderBy(r => r.Position, StringComparer.Ordinal)
    .LastOrDefault();

var batch = TableOpBatch.Create(
    new InsertRowOp(
        RowId:     Guid.NewGuid().ToString("n"),
        Position:  PositionAfter(lastRow?.Position, null),
        Timestamp: NextTs(),
        PeerId:    myPeerId));

await client.SendOpAsync(docId, JsonSerializer.SerializeToUtf8Bytes(batch), baseRevision);

Editing a cell

var setOp = typed.BuildSetCell(
    rowId:     rowId,
    columnId:  columnId,
    value:     new TextValue("Hello"),
    timestamp: NextTs(),
    peerId:    myPeerId);

await client.SendOpAsync(docId, JsonSerializer.SerializeToUtf8Bytes(
    new TableOpBatch(new TableOp[] { setOp })),
    baseRevision);

If two users edit the same cell concurrently, the higher timestamp wins; ties are broken by ordinal PeerId compare. Both replicas converge.

Deleting a column

var batch = TableOpBatch.Create(
    new RemoveColumnOp(columnId, NextTs(), myPeerId));

The column is soft-deleted — its IsDeleted flag flips on but existing cells in that column stay in storage. UI filters them at render time. If another user concurrently edited a cell in that column, their write is preserved (just not rendered while the column is tombstoned).

A later RestoreColumnOp un-tombstones the column and brings the cell values back into view.

Reordering columns

var newPos = PositionAfter(siblingLeft.Position, siblingRight.Position);

var batch = TableOpBatch.Create(
    new MoveColumnOp(columnId, newPos, NextTs(), myPeerId));

Updating a column definition

var newDef = JsonSerializer.SerializeToElement(
    new ColumnDef("Priority", "select", MaxLength: 20));

var batch = TableOpBatch.Create(
    new UpdateColumnDefinitionOp(columnId, newDef, NextTs(), myPeerId));

Rendering — filter tombstones

IEnumerable<RowMeta> VisibleRows(TableDocument doc) =>
    doc.Rows.Values
        .Where(r => !r.IsDeleted)
        .OrderBy(r => r.Position, StringComparer.Ordinal);

IEnumerable<ColumnMeta> VisibleColumns(TableDocument doc) =>
    doc.Columns.Values
        .Where(c => !c.IsDeleted)
        .OrderBy(c => c.Position, StringComparer.Ordinal);

CellValue? ReadCell(TableDocument doc, string rowId, string columnId) =>
    doc.Cells.TryGetValue(new CellAddress(rowId, columnId), out var reg)
        ? typed.ReadCell(reg)
        : null;

Undo / Redo

TableCrdtEngine overrides RestampToWin, so UndoRedoEngine<TableDocument, TableOpBatch> produces undoes that stay visible under heavy concurrent editing. See Undo / Redo.

Per-cell awareness

Show which cell each peer is editing:

await client.SendAwarenessAsync(docId,
    JsonSerializer.SerializeToElement(new
    {
        focused = new { rowId, columnId },
        name = currentUserName,
        color = currentUserColor,
    }));

Render a small highlighted border in each peer's color on the referenced cell.