useYTable.ts26.7 KBView on GitHub
'use client';

/**
 * `useYTable` — the grid's binding to a table document's Y.Doc.
 *
 * Owns exactly what `<Document />` owns for prose documents (the refcounted provider,
 * the IDB-first warm/cold seed, the release on unmount) but binds the two sibling
 * top-level Y types instead of mounting a TipTap editor:
 *
 *   ydoc.getMap('tableSchema')  → the column schema
 *   ydoc.getArray('tableRows')  → one flat Y.Map per row
 *
 * The Y layout itself is NOT re-implemented here — every read and write goes through
 * `@zero/server/table/ydoc`, the single module that knows the layout, so the client and
 * the agent writer cannot drift on things like "an empty cell is absent, not `''`".
 *
 * ── Why the row list is a list of Y.Map HANDLES ──
 *
 * The hook deliberately does not materialize cell values. It returns one handle per row
 * and each row component subscribes to its OWN Y.Map through `useYRowCells`. That is what
 * makes re-rendering granular: an agent filling column `outreach` on row 400 notifies
 * row 400's subscriber and nobody else. If the hook returned `TableRow[]` instead, every
 * cell write would produce a new array and re-render all 1,000 rows.
 *
 * The row-list state is therefore only ever replaced when the row ORDER or MEMBERSHIP
 * changes. `observeDeep` fires for cell writes too, so the sync bails out on an unchanged
 * ordering and React's identity bailout keeps the grid from re-rendering at all.
 *
 * ── The sorted view ──
 *
 * `schema.sort` orders the HANDLE LIST and never the Y.Array: sorting is a view, so document
 * order — which is what `_id` writes, the ordinal forms and every CRDT identity rest on —
 * stays exactly as it was. The cost is that the bailout above needs one more question. A cell
 * write to the SORTED column can change the order, so it is the one cell write that must be
 * allowed through; every other one still bails on the first line, which is what keeps a 1,000-row
 * fan-out from re-sorting once per filled cell.
 */

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Y from 'yjs';
import {
  RESERVED_COLUMN,
  TABLE_MAX_ROWS,
  Y_TABLE_ROWS,
  Y_TABLE_SCHEMA,
  findColumn,
  isSectionCells,
  type TableRow,
  type TableSchema,
} from '@zero/server/table';
import { isSortableColumnKey, sortWithinSections } from '@zero/server/table/sort';
import {
  appendRows,
  deleteRowsByIds,
  findRowIndex,
  findRowMap,
  makeRowMap,
  mintRowId,
  removeColumn as removeColumnFromYDoc,
  rowMapToRow,
  setCell as setCellInRowMap,
  tableRowsArray,
  tableSchemaMap,
  writeTableSchema,
  type TableRowMap,
} from '@zero/server/table/ydoc';
import {
  acquireProvider,
  base64ToUint8Array,
  createTrpcApplyUpdateClient,
  releaseProvider,
} from '@/modules/documents/yjs';
import { useTRPCClient } from '@/providers/query-provider';
import { parseTableSchema } from './parse-table-schema';

/**
 * Transaction origin for every edit this grid makes. Two things key off it:
 *   - `CedarYjsProvider` flushes anything whose origin isn't server-pushed, so local
 *     edits reach the server (see its SERVER_PUSHED_ORIGINS).
 *   - the UndoManager tracks it, which is how Cmd+Z reverts the user's own edits
 *     without touching another human's (those arrive tagged `'human'`).
 */
export const GRID_ORIGIN = 'table-grid';

/**
 * Origin for repairs of pre-existing content (a row with no `_id`).
 *
 * Chosen to be in NEITHER the UndoManager's `trackedOrigins` nor the provider's
 * `SERVER_PUSHED_ORIGINS`, which is what makes a repair persist without becoming the user's
 * first undo step.
 */
export const REPAIR_ORIGIN = 'table-grid-repair';

/** Same 2s budget, same reasoning, as `<Document />`'s IDB race. */
const IDB_HYDRATE_TIMEOUT_MS = 2000;

/** One cell write, for the batched `setCells`. */
export interface TableCellEdit {
  rowId: string;
  columnKey=[redacted];
  value: string;
}

/** A row, addressed rather than materialized. `map` is stable across cell writes. */
export interface TableRowHandle {
  rowId: string;
  map: TableRowMap;
  /**
   * A heading rather than a record — see `RESERVED_COLUMN.SECTION`.
   *
   * Carried ON the handle, and compared by `sameOrdering`, because it is part of the row list's
   * SHAPE rather than a cell value. The list is otherwise only replaced when the order or
   * membership changes, and a `_section` write changes neither — so without this, typing a
   * heading into a row left the grid rendering it as an ordinary row until something unrelated
   * moved. It is also what the sort's segmenter and the selection's span check read, so all
   * three answers come from one place.
   */
  isSection: boolean;
}

export interface UseYTableResult {
  /** Null only while the schema is unreadable — `schemaError` says why. */
  schema: TableSchema | null;
  /** Human-readable reason the schema could not be read, for the error card. */
  schemaError: string | null;
  rows: TableRowHandle[];
  /** True until the server seed has been applied (or has failed). */
  isLoading: boolean;
  /**
   * The server has no such document — it was deleted while (or since) this browser held a copy.
   *
   * Y.Docs persist to IndexedDB so a table opens instantly and survives a reload offline. The
   * cost is that a DELETED table kept rendering from that cache, fully interactive, with the
   * 404 going to `console.error` and nothing else: the grid showed rows, accepted edits, and
   * flushed them at a row that no longer exists. (Observed on a table an agent deleted and
   * rebuilt mid-conversation — the user's link still opened the dead copy, with the OLD column
   * options, while the live table had different ones.) The local store is dropped when this
   * is set, so the phantom does not come back on the next open.
   */
  isDeleted: boolean;
  setCell: (rowId: string, columnKey=[redacted], value: string) => void;
  /**
   * Many cells in ONE transaction — one undo step, one flush, one delta. The paste and the
   * range-clear paths need this: as N calls to `setCell` a 50×5 paste is 250 undo steps and
   * 250 flushes, and undoing it is 250 presses of Cmd+Z.
   */
  setCells: (edits: TableCellEdit[]) => void;
  /** Appends one row; returns its fresh rowId. */
  addRow: (cells?: Record<string, string>) => string;
  /** Appends several rows in one transaction — one undo step, one flush. */
  addRows: (rows: Array<Record<string, string>>) => string[];
  deleteRow: (rowId: string) => void;
  /** Moves a row within the Y.Array. */
  moveRow: (fromIndex: number, toIndex: number) => void;
  /**
   * Insert a heading row above or below an existing row, in DOCUMENT order.
   *
   * Anchored by rowId rather than by the rendered index because those are different lists the
   * moment a sort is on, and a section is document-order structure — it is the thing the sort
   * runs INSIDE. The grid withholds the action while sorted for exactly that reason, the same
   * way it withholds the drag handle. Returns the new rowId.
   */
  insertSection: (anchorRowId: string, where: 'above' | 'below', heading?: string) => string;
  setSchema: (next: TableSchema) => void;
  /**
   * Drops a column from the schema AND from every row. Pass the schema the caller wants
   * left behind (from `dropColumn`) so `titleColumn` is repaired in the same transaction.
   */
  removeColumn: (columnKey=[redacted], nextSchema?: TableSchema) => void;
  undo: () => void;
  redo: () => void;
}

export function useYTable(documentId: string | null): UseYTableResult {
  // Always a fresh Y.Doc, seeded via applyInitialState — the contract
  // `providerRegistry` documents, and the same thing `<Document />` does.
  const ydocRef = useRef<Y.Doc | null>(null);
  const prevDocIdRef = useRef<string | null>(null);
  if (!ydocRef.current || prevDocIdRef.current !== documentId) {
    prevDocIdRef.current = documentId;
    ydocRef.current = new Y.Doc();
  }
  const ydoc = ydocRef.current;

  const trpcClient = useTRPCClient();
  const trpcClientRef = useRef(trpcClient);
  trpcClientRef.current = trpcClient;

  const [schemaState, setSchemaState] = useState(() => parseTableSchema(null));
  const [rows, setRows] = useState<TableRowHandle[]>([]);
  const [isLoading, setIsLoading] = useState(!!documentId);
  const [isDeleted, setIsDeleted] = useState(false);

  /**
   * Grid-scoped undo. Prose documents inherit one from TipTap's `Collaboration`
   * extension; there is no editor here, so without this the grid would have no undo at
   * all. `trackedOrigins` mirrors `Collaboration`'s `yUndoOptions` — our own edits plus
   * agent writes (so Cmd+Z can take back what an agent just filled in), never another
   * human's, which arrive tagged `'human'`.
   *
   * `captureTimeout: 0` makes one committed cell edit exactly one undo step. The default
   * 500ms window would fold two unrelated cell commits into a single step.
   */
  const undoManager = useMemo(() => {
    return new Y.UndoManager([ydoc.getMap(Y_TABLE_SCHEMA), ydoc.getArray(Y_TABLE_ROWS)], {
      trackedOrigins: new Set([GRID_ORIGIN, 'agent']),
      captureTimeout: 0,
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps -- ydoc is reassigned in render when documentId changes
  }, [documentId]);
  useEffect(() => () => undoManager.destroy(), [undoManager]);

  // Last ordering pushed into state. Compared BEFORE calling setRows so an unchanged
  // ordering costs nothing at all — not even React's own identity bailout.
  const orderingRef = useRef<TableRowHandle[]>([]);

  // ── Bind the schema map and the rows array ──
  useEffect(() => {
    const schemaMap = tableSchemaMap(ydoc);
    const rowsArray = tableRowsArray(ydoc);

    /**
     * The last schema this effect saw, because `syncRows` needs it and cannot read React state
     * (it runs inside a Y observer, where `schemaState` is whatever it closed over). Two things
     * come off it: WHICH column orders the rows, and the column DECLARATION the comparator
     * dispatches on — a `select` sorts by its declared options, so changing them reorders the
     * table without a single cell changing.
     */
    let boundSchema: TableSchema | null = null;

    const syncSchema = () => {
      const parsed = parseTableSchema(schemaMap.toJSON());
      setSchemaState(parsed);
      const next = parsed.ok ? parsed.schema : null;
      const reordered = sortSignature(next) !== sortSignature(boundSchema);
      boundSchema = next;
      // A schema write is not a row write, so nothing else would re-run the comparator — and
      // "sort by Amount" that leaves the rows where they are is the whole feature not working.
      if (reordered) syncRows(undefined, true);
    };

    const syncRows = (events?: Y.YEvent<Y.AbstractType<unknown>>[], force = false) => {
      // `observeDeep` fires for CELL writes too, and rebuilding the handle list plus scanning
      // for orphans is O(rows) — so a fan-out over 1,000 rows paid ~1,000 allocations per cell
      // write for an answer that is almost always "nothing moved". The render bailout below
      // worked; the scan behind it did not. When the events say the array itself is unchanged,
      // there is nothing for this function to do.
      //
      // Unless the write landed in the column the view is SORTED by, where a changed value can
      // genuinely move the row — or in `_section`, which is structural in a second way: a
      // heading appearing or clearing re-cuts the SEGMENTS the sort runs inside, so every row
      // below it can move without any of their own cells changing.
      // `_section` is structural ALWAYS, not only under a sort: it changes what the row IS —
      // a band instead of N cells, one selectable column instead of many, out of the fan-out —
      // so the list has to be rebuilt even when nothing moves. The sort column is structural
      // only when there IS a sort, because otherwise its value cannot reorder anything.
      const sortKey=[redacted];
      const structuralKeys = sortKey
        ? [sortKey, RESERVED_COLUMN.SECTION]
        : [RESERVED_COLUMN.SECTION];
      if (
        events &&
        !force &&
        !events.some((e) => e.target === rowsArray) &&
        !structuralKeys.some((key) => events.some((e) => eventTouchesKey(e, key)))
      ) {
        return;
      }

      const next = rowsArray.toArray().map((map) => ({
        rowId: readRowId(map),
        map,
        isSection: isSectionCells({
          [RESERVED_COLUMN.SECTION]: readCell(map, RESERVED_COLUMN.SECTION),
        }),
      }));
      // A row with no `_id` is unaddressable — `setCell` could never find it — so repair
      // it rather than render a row the user can't edit. Only legacy/hand-edited content
      // hits this; the repair is deferred out of the observer so we never mutate a Y type
      // from inside its own notification.
      const orphans = next.filter((handle) => handle.rowId === '');
      if (orphans.length > 0) {
        queueMicrotask(() => {
          // `REPAIR_ORIGIN`, deliberately neither GRID_ORIGIN nor a server-pushed origin: it
          // must PERSIST (so the ids are minted once rather than freshly on every open) but must
          // not be UNDOABLE. Under GRID_ORIGIN the UndoManager tracked it, so the first Cmd+Z
          // after opening a legacy table un-repaired the row ids; under `'system'` the provider
          // would skip the flush and the repair would never reach the server.
          ydoc.transact(() => {
            for (const orphan of orphans) {
              if (readRowId(orphan.map) === '') orphan.map.set(RESERVED_COLUMN.ID, mintRowId());
            }
          }, REPAIR_ORIGIN);
        });
      }
      const ordered = applySort(next, boundSchema);
      if (sameOrdering(orderingRef.current, ordered)) return;
      orderingRef.current = ordered;
      setRows(ordered);
    };

    const onSchemaChange = () => syncSchema();
    const onRowsChange = (events: Y.YEvent<Y.AbstractType<unknown>>[]) => syncRows(events);

    orderingRef.current = [];
    syncSchema();
    syncRows();
    schemaMap.observeDeep(onSchemaChange);
    rowsArray.observeDeep(onRowsChange);
    return () => {
      schemaMap.unobserveDeep(onSchemaChange);
      rowsArray.unobserveDeep(onRowsChange);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- ydoc is reassigned in render when documentId changes
  }, [documentId]);

  // ── Provider lifecycle: acquire → race IDB → seed from server → release ──
  // Byte-for-byte the lifecycle `<Document />` runs; see its comments for why the
  // server seed is unconditional and why the IDB race has a timeout.
  useEffect(() => {
    if (!documentId) {
      setIsLoading(false);
      return;
    }
    setIsLoading(true);
    const provider = acquireProvider({
      documentId,
      client: createTrpcApplyUpdateClient(),
      ydoc,
      onError: (err) => console.error('[useYTable] provider error', err),
    });
    let cancelled = false;
    let idbSettled = false;
    void provider.idbReady.then(
      () => {
        idbSettled = true;
      },
      () => {
        idbSettled = true;
      },
    );
    void Promise.race([
      provider.idbReady.catch(() => {
        /* a hang or rejection is handled through idbSettled below */
      }),
      new Promise<void>((resolve) => setTimeout(resolve, IDB_HYDRATE_TIMEOUT_MS)),
    ])
      .then(async () => {
        if (cancelled) return;
        if (!idbSettled) {
          console.error('[useYTable] IndexedDB hydration stalled; clearing local store for', documentId);
          try {
            indexedDB.deleteDatabase(`cedar-doc-${documentId}`);
          } catch {
            /* best effort */
          }
        }
        const seed = await trpcClientRef.current.documents.getDoc.query({ documentId });
        if (cancelled || !seed.contentYjs) return;
        try {
          provider.applyInitialState(base64ToUint8Array(seed.contentYjs));
        } catch (persistErr) {
          console.warn('[useYTable] applyInitialState persist warning', documentId, persistErr);
        }
      })
      .catch((err) => {
        if (cancelled) return;
        // NOT_FOUND is not a hydration failure, it is an ANSWER: the row is gone (getDocImpl
        // filters `deletedAt`). Keeping the locally-cached copy on screen after that is the
        // one outcome that must not happen — see `isDeleted`.
        if (isDocumentGone(err)) {
          setIsDeleted(true);
          try {
            indexedDB.deleteDatabase(`cedar-doc-${documentId}`);
          } catch {
            /* best effort — the flag already stops the grid rendering */
          }
          return;
        }
        console.error('[useYTable] failed to hydrate Y.Doc from server', documentId, err);
      })
      .finally(() => {
        if (!cancelled) setIsLoading(false);
      });
    return () => {
      cancelled = true;
      releaseProvider(documentId);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- ydoc is reassigned in render when documentId changes
  }, [documentId]);

  // ── Mutations ──
  // Every one runs in a single transaction tagged GRID_ORIGIN: one undo step, one flush,
  // and (for the row-granular writes) a delta of a handful of bytes.

  const setCell = useCallback(
    (rowId: string, columnKey=[redacted], value: string) => {
      const map = findRowMap(ydoc, rowId);
      if (!map) return;
      ydoc.transact(() => setCellInRowMap(map, columnKey, value), GRID_ORIGIN);
    },
    [ydoc],
  );

  const setCells = useCallback(
    (edits: TableCellEdit[]) => {
      if (edits.length === 0) return;
      ydoc.transact(() => {
        // Row maps are looked up once per row rather than once per cell: `findRowMap` is a
        // linear scan of the Y.Array, so a 50-row paste would otherwise be 250 scans.
        const maps = new Map<string, TableRowMap | undefined>();
        for (const edit of edits) {
          if (!maps.has(edit.rowId)) maps.set(edit.rowId, findRowMap(ydoc, edit.rowId));
          const map = maps.get(edit.rowId);
          if (map) setCellInRowMap(map, edit.columnKey, edit.value);
        }
      }, GRID_ORIGIN);
    },
    [ydoc],
  );

  const addRows = useCallback(
    (cellsList: Array<Record<string, string>>) => {
      // The cap the whole performance story rests on — every save rewrites the full document, so
      // it is what keeps a single-cell write cheap. The agent writer enforced it and this path
      // did not, so a paste-heavy user could walk a table straight past it. Truncating rather
      // than throwing, because a paste that is one row too long should still land what fits.
      const room = Math.max(0, TABLE_MAX_ROWS - tableRowsArray(ydoc).length);
      const accepted = cellsList.slice(0, room);
      if (accepted.length === 0) return [];
      const newRows: TableRow[] = accepted.map((cells) => ({ rowId: mintRowId(), cells }));
      let ids: string[] = [];
      ydoc.transact(() => {
        ids = appendRows(ydoc, newRows);
      }, GRID_ORIGIN);
      return ids;
    },
    [ydoc],
  );

  const addRow = useCallback((cells: Record<string, string> = {}) => addRows([cells])[0] ?? '', [addRows]);

  const deleteRow = useCallback(
    (rowId: string) => {
      ydoc.transact(() => deleteRowsByIds(ydoc, [rowId]), GRID_ORIGIN);
    },
    [ydoc],
  );

  const moveRow = useCallback(
    (fromIndex: number, toIndex: number) => {
      const array = tableRowsArray(ydoc);
      if (fromIndex === toIndex || fromIndex < 0 || fromIndex >= array.length) return;
      const target = Math.max(0, Math.min(toIndex, array.length - 1));
      // Y.Array has no move, and a Y type cannot be re-inserted once it belongs to a
      // document — so the row is rebuilt from its materialized form. That costs the row's
      // CRDT identity (a concurrent edit to a moved row loses), which is why reorder is an
      // explicit drag rather than something the agent writer ever does.
      ydoc.transact(() => {
        const row = rowMapToRow(array.get(fromIndex));
        array.delete(fromIndex, 1);
        array.insert(target, [makeRowMap(row)]);
      }, GRID_ORIGIN);
    },
    [ydoc],
  );

  const insertSection = useCallback(
    (anchorRowId: string, where: 'above' | 'below', heading = 'New section') => {
      const array = tableRowsArray(ydoc);
      if (array.length >= TABLE_MAX_ROWS) return '';
      const anchor = findRowIndex(ydoc, anchorRowId);
      // An unknown anchor appends rather than throwing: the row could have been deleted by
      // another client between the menu opening and the click, and the end of the table is a
      // defensible place for a heading nobody can place more precisely.
      const at = anchor === -1 ? array.length : where === 'above' ? anchor : anchor + 1;
      const rowId = mintRowId();
      ydoc.transact(() => {
        array.insert(at, [makeRowMap({ rowId, cells: { [RESERVED_COLUMN.SECTION]: heading } })]);
      }, GRID_ORIGIN);
      return rowId;
    },
    [ydoc],
  );

  const setSchema = useCallback(
    (next: TableSchema) => {
      ydoc.transact(() => writeTableSchema(ydoc, next), GRID_ORIGIN);
    },
    [ydoc],
  );

  const removeColumn = useCallback(
    (columnKey=[redacted], nextSchema?: TableSchema) => {
      ydoc.transact(() => {
        removeColumnFromYDoc(ydoc, columnKey);
        // `removeColumn` drops the column from the schema and from every row but knows
        // nothing about `titleColumn`, which must keep pointing at a column that exists.
        // Same transaction so the two land as one undo step and one delta.
        if (nextSchema) writeTableSchema(ydoc, nextSchema);
      }, GRID_ORIGIN);
    },
    [ydoc],
  );

  const undo = useCallback(() => undoManager.undo(), [undoManager]);
  const redo = useCallback(() => undoManager.redo(), [undoManager]);

  return {
    schema: schemaState.ok ? schemaState.schema : null,
    schemaError: schemaState.ok ? null : schemaState.error,
    rows,
    isLoading,
    isDeleted,
    setCell,
    setCells,
    addRow,
    addRows,
    deleteRow,
    moveRow,
    insertSection,
    setSchema,
    removeColumn,
    undo,
    redo,
  };
}

/**
 * Is this error the server saying the document does not exist, rather than a transport failure?
 *
 * Matched on the tRPC error SHAPE (`data.code`) with the message as a fallback, because the
 * distinction decides whether a locally-cached table is shown or thrown away — and throwing
 * away someone's offline copy on a flaky connection would be the worse of the two mistakes.
 */
function isDocumentGone(err: unknown): boolean {
  const code = (err as { data?: { code?: string } } | undefined)?.data?.code;
  if (code) return code === 'NOT_FOUND';
  const message = err instanceof Error ? err.message : '';
  return /not found/i.test(message);
}

/**
 * Subscribe one row component to one row's Y.Map.
 *
 * This is the other half of the granularity story: the row list above never carries cell
 * values, so this is the only thing a cell write notifies. `observeDeep` rather than
 * `observe` so an in-place Y.Text edit (a `long_text` cell) also lands.
 */
export function useYRowCells(map: TableRowMap): Record<string, string> {
  const [cells, setCells] = useState<Record<string, string>>(() => rowMapToRow(map).cells);
  useEffect(() => {
    const onChange = () => setCells(rowMapToRow(map).cells);
    onChange();
    map.observeDeep(onChange);
    return () => map.unobserveDeep(onChange);
  }, [map]);
  return cells;
}

function readRowId(map: TableRowMap): string {
  return readCell(map, RESERVED_COLUMN.ID);
}

/** One cell as text. A `long_text` cell is a Y.Text, so `map.get` is not already a string. */
function readCell(map: TableRowMap, columnKey=[redacted] string {
  const value = map.get(columnKey);
  return value instanceof Y.Text ? value.toString() : typeof value === 'string' ? value : '';
}

/**
 * The column the rows are ordered by, or null for document order.
 *
 * Null for a sort naming a column that no longer exists, which is a real state: another client
 * can delete the sorted column while this one is looking at it. Falling back to document order
 * is the honest rendering — the alternative is rows arranged by comparing empty strings.
 */
function activeSortKey(schema: TableSchema | null): string | null {
  if (!schema?.sort) return null;
  return isSortableColumnKey(schema, schema.sort.columnKey) ? schema.sort.columnKey=[redacted];
}

/**
 * Everything about the schema that can change the ROW ORDER, as a string.
 *
 * Not just the sort itself: the sorted column's own declaration is half the comparator (a
 * `select`'s option order, a `percent`'s unit, a `date`'s parse), so reordering a select's
 * options has to re-sort the grid even though `sort` is untouched.
 */
function sortSignature(schema: TableSchema | null): string {
  if (!schema?.sort) return '';
  const { columnKey, direction } = schema.sort;
  return JSON.stringify([columnKey, direction, findColumn(schema, columnKey) ?? null]);
}

/**
 * Order the handle list per the schema. Returns the input untouched when nothing sorts it.
 *
 * Through `sortWithinSections`, the same function `read` uses — so a heading holds its place on
 * screen exactly as it does in a projection, and the two cannot disagree about where a row went.
 */
function applySort(handles: TableRowHandle[], schema: TableSchema | null): TableRowHandle[] {
  const sortKey=[redacted];
  if (!sortKey || !schema?.sort) return handles;
  return sortWithinSections(
    handles,
    (handle) => handle.isSection,
    (handle) => readCell(handle.map, sortKey),
    findColumn(schema, sortKey),
    schema.sort.direction,
    sortKey,
  );
}

/**
 * Did this event touch `columnKey` on some row?
 *
 * Two shapes, because a cell is stored two ways. An ordinary cell is a string ON the row's
 * Y.Map, so the row map's own event carries the key in `keys`. A `long_text` cell is a Y.Text
 * INSIDE it, and typing in one produces an event on the text itself — whose `path`, relative
 * to the observed array, is `[rowIndex, columnKey]`. Checking only `keys` would miss every
 * edit to a long-text column the table is sorted by.
 */
function eventTouchesKey(event: Y.YEvent<Y.AbstractType<unknown>>, columnKey=[redacted] boolean {
  if (event.path.includes(columnKey)) return true;
  return event instanceof Y.YMapEvent && event.keys.has(columnKey);
}

/**
 * Same rows, same order, same Y.Maps, same KIND — the test for "nothing structural happened".
 *
 * `isSection` is part of that test because a row becoming a heading changes how it renders and
 * how it is navigated without moving anything, and this bailout is the only thing between a Y
 * event and a re-render.
 */
function sameOrdering(a: TableRowHandle[], b: TableRowHandle[]): boolean {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) {
    const left = a[i];
    const right = b[i];
    if (!left || !right) return false;
    if (left.rowId !== right.rowId || left.map !== right.map) return false;
    if (left.isSection !== right.isSection) return false;
  }
  return true;
}