compact-table.ts3.2 KBView on GitHub
/**
 * "Compact" for a table document — the manual trigger for the server's Y.Doc rebuild.
 *
 * A table's `content_yjs` grows with EDIT COUNT, not row count: interleaved cell rewrites
 * defeat Y.js's item merging, so a static 200-row table whose statuses churn accrues bytes
 * while the row count never moves (measured at ~9.7 bytes per interleaved write — see
 * `apps/server/src/services/documents/table/table-compaction.ts`). Compaction rebuilds the
 * doc from its current schema and rows, and the server normally does it for itself on the
 * next write after the bloat detector raises `metadata.compactionDue`. This is the manual
 * override for the case where someone is looking at a table that has visibly bloated and does
 * not want to wait for the next write to trigger it.
 *
 * ── Why a narrowed client interface rather than the typed proxy ──
 * Same reason `download-table.ts` does it: the call is described by the one field it sends and
 * the four it reads, so a test double is a plain object rather than a mock of the tRPC proxy.
 * Here it also buys the toolbar independence from the exact moment the procedure lands on
 * `AppRouter` — the shape is the contract, and it is written down once, here.
 *
 * ── The procedure: `documents.compactTable({ documentId })` ──
 * A `privateProcedure`, deliberately separate from the staff-only `admin.tables.compact`
 * (which is path-keyed, for an operator investigating someone else's table). This one is a
 * user acting on their own document, so it cannot be staff-gated — and the view holds a
 * documentId, not a path. Both call the same `compactTableDocument` service, so there is
 * one compaction implementation.
 *
 * It passes `force: true`, because a human clicking the button means the bloat threshold is
 * not a reason to skip. What can still decline is an attached viewer — replacing server
 * state under an open editor would discard its unflushed edits — which `canCompactNow`
 * defers and the returned `note` names. That arrives here as `success: false`, and the
 * toolbar reports it as deferred rather than as done.
 */

export interface TableCompactionResult {
  /** False when the server declined — e.g. a subscriber was attached, so it deferred. */
  success: boolean;
  bytesBefore: number;
  bytesAfter: number;
  /** `bytesBefore - bytesAfter`, computed server-side so the two cannot disagree. */
  reclaimedBytes: number;
}

/** The client surface `compactTableDocument` needs, narrowed so callers can pass a double. */
export interface TableCompactClient {
  documents: {
    compactTable: {
      mutate: (input: { documentId: string }) => Promise<TableCompactionResult>;
    };
  };
}

export function compactTableDocument(
  client: TableCompactClient,
  documentId: string,
): Promise<TableCompactionResult> {
  return client.documents.compactTable.mutate({ documentId });
}

/**
 * Bytes, for a human reading a toast. Same thresholds and precision as the attachment-size
 * formatter in `modules/files`, so two byte counts in the same product read the same way.
 */
export function formatByteCount(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}