Data Table Filters
blockdata-table-schemasource: ok · refreshed
Data Table Schema System
Declarative table definitions with col.* factories, presets, and generators for columns, filters, and sheet fields.
npx shadcn add "https://logs.run/r/data-table-schema.json"free
Where it goes
data-table
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@tanstack/react-table@^9.2.3@tanstack/react-query@^5.101.4
Registry dependencies
https://data-table.openstatus.dev/r/data-table.jsonhttps://data-table.openstatus.dev/r/data-table-cell.jsonhttps://data-table.openstatus.dev/r/data-table-sheet.jsonhttps://data-table.openstatus.dev/r/data-table-filter-command.json
Files20 · source cached 10/2/2026, 1:52:48 AM
src/lib/table-schema/index.ts
import { col as _col, resolveColumns } from "./col";
import { presets } from "./presets";
import {
deserializeSchema,
migrateSchemaJSON,
serializeSchema,
} from "./serialize";
import { validateSchema } from "./validate";
/**
* Column builder factories and presets for defining table schemas.
*
* **Primitive factories** — choose based on the data type of the column:
* - `col.string()` — text data (`string`)
* - `col.number()` — numeric data (`number`)
* - `col.boolean()` — boolean data (`boolean`)
* - `col.timestamp()` — date/time data (`Date`)
* - `col.enum(values)` — string union (`T[number]`)
* - `col.array(item)` — array of values (`U[]`)
* - `col.record()` — key-value map (`Record<string, string>`)
*
* **Presets** — pre-configured builders for common log table patterns:
* - `col.presets.logLevel(values)` — severity levels
* - `col.presets.httpMethod(values)` — HTTP verbs
* - `col.presets.httpStatus(codes?)` — HTTP status codes
* - `col.presets.duration(unit?, slider?)` — timing / latency
* - `col.presets.timestamp()` — sortable timestamp with timerange filter
* - `col.presets.traceId()` — trace / request ID (code display, not filterable)
* - `col.presets.pathname()` — URL path with text search
*
* @example
* ```ts
* import { col, createTableSchema } from "@/lib/table-schema";
*
* export const tableSchema = createTableSchema({
* level: col.presets.logLevel(LEVELS).description("Log severity"),
* date: col.presets.timestamp().label("Date").size(200).sheet(),
* latency: col.presets.duration("ms").label("Latency").sortable().size(110).sheet(),
* status: col.presets.httpStatus().label("Status").size(60),
* method: col.presets.httpMethod(METHODS).size(69),
* host: col.string().label("Host").size(125).sheet(),
* headers: col.record().label("Headers").hidden().sheet(),
* });
* ```
*/
export const col = { ..._col, presets };
export type {
ColBuilder,
ColKind,
ColRenderers,
ColumnDescriptor,
ColumnDescriptorCommon,
DatePresetDescriptor,
DisplayConfig,
DisplayDescriptor,
FilterDescriptor,
FilterType,
InferTableType,
JsonValue,
OptionDescriptor,
Provenance,
ResolvedColumn,
ResolvedColumnEntry,
SchemaJSON,
SchemaJSONVersion,
SerializableDisplayConfig,
SheetConfig,
SheetDescriptor,
TableSchemaDefinition,
} from "./types";
export { resolveColumn, resolveColumns } from "./col";
export {
NAMED_DISPLAY_TYPES,
applyRenderers,
isNamedDisplayType,
type ApplyRenderersOptions,
type NamedDisplayType,
type RendererOverrides,
} from "./renderers";
export { generateColumns } from "./generators/columns";
export { generateFilterFields } from "./generators/filter-fields";
export { generateFilterSchema } from "./generators/filter-schema";
export { generateSheetFields } from "./generators/sheet-fields";
export {
SCHEMA_JSON_VERSION,
deserializeSchema,
migrateSchemaJSON,
serializeSchema,
} from "./serialize";
export {
DEFAULT_CAPABILITIES,
MANIFEST_LIMITS,
TABLE_MANIFEST_VERSION,
TableManifestError,
createRowAccessors,
createTableManifest,
createTableManifestHandler,
fetchTableManifest,
isSafeColumnKey,
manifestETag,
parseTableManifest,
type FetchManifestOptions,
type ManifestHandlerOptions,
type ParseManifestOptions,
type RowAccessors,
} from "./manifest";
export {
type TableCapabilities,
type TableChartConfig,
type TableManifest,
type TableManifestDefaults,
} from "./manifest";
export {
manifestToModule,
pullManifestModule,
type SnapshotOptions,
} from "./snapshot";
/**
* Derive defaultColumnVisibility from the schema.
* Returns { [key]: false } for every column marked with .hidden().
*/
export function getDefaultColumnVisibility(
schema: import("./types").TableSchemaDefinition,
): Record<string, boolean> {
const visibility: Record<string, boolean> = {};
for (const { key, hidden } of resolveColumns(schema)) {
if (hidden) visibility[key] = false;
}
return visibility;
}
/**
* Create a table schema from a map of col.* builders.
*
* The returned object holds the definition and exposes:
* - `toJSON()` — serializes the schema to a function-free JSON descriptor,
* suitable for AI agents, MCP tools, and `JSON.stringify`.
*
* Use `createTableSchema.fromJSON(json)` to reconstruct a schema from a
* JSON descriptor (e.g. one generated by an AI agent). Custom renderers
* (display.cell, filter.component, sheet.component) are not serialized and
* must be applied manually on top of the reconstructed builders.
*
* @example
* ```ts
* export const tableSchema = createTableSchema({
* level: col.enum(LEVELS).label("Level").defaultOpen().sheet(),
* date: col.timestamp().label("Date").sortable().size(200).sheet(),
* });
*
* export type ColumnSchema = InferTableType<typeof tableSchema.definition>;
*
* // Serialize for an AI agent or MCP tool
* const json = tableSchema.toJSON();
* // JSON.stringify(tableSchema) also works — toJSON() is called automatically
*
* // Reconstruct from AI-generated JSON
* const schema = createTableSchema.fromJSON(json);
* ```
*/
export function createTableSchema<
T extends import("./types").TableSchemaDefinition,
>(definition: T): { definition: T; toJSON(): import("./types").SchemaJSON } {
validateSchema(definition);
return {
definition,
toJSON() {
return serializeSchema(definition);
},
};
}
/**
* Reconstruct a schema from a JSON descriptor.
*
* Takes `unknown` on purpose: the builder pastes user-typed text in here, and
* the cache replays JSON written by an older build. `migrateSchemaJSON` brings
* older versions forward and throws on anything unrecognisable, so the cast
* that used to sit on untrusted input is gone.
*/
createTableSchema.fromJSON = (
json: unknown,
): {
definition: import("./types").TableSchemaDefinition;
toJSON(): import("./types").SchemaJSON;
} => {
return createTableSchema(deserializeSchema(migrateSchemaJSON(json)));
};
src/lib/table-schema/col.ts
import type {
DatePreset,
Option,
} from "@/components/data-table/types";
import type {
ColBuilder,
ColKind,
ColRenderers,
ColumnDescriptor,
ColumnDescriptorCommon,
DatePresetDescriptor,
DisplayDescriptor,
FilterDescriptor,
FilterType,
OptionDescriptor,
Provenance,
ResolvedColumn,
ResolvedColumnEntry,
SheetConfig,
TableSchemaDefinition,
} from "./types";
// ── Per-kind defaults — the ONE definition ──────────────────────────────────
/**
* The default cell display for a column kind.
*
* This is the single definition. `infer.ts`, `generators/sheet-fields.ts`, and
* `serialize.ts` each used to carry their own copy, and the third disagreed
* with the other two.
*/
export function defaultDisplayForKind(kind: ColKind): DisplayDescriptor {
switch (kind) {
case "number":
return { type: "number" };
case "boolean":
case "select":
return { type: "boolean" };
case "timestamp":
return { type: "timestamp" };
case "enum":
case "array":
return { type: "badge" };
case "string":
case "record":
return { type: "text" };
}
}
const MANUAL: Provenance = { source: "manual" };
/**
* Drop keys whose value is `undefined`.
*
* Descriptors have to be canonical: `JSON.stringify` erases an explicit
* `undefined`, so a descriptor carrying one compares unequal to its own round
* trip under `toStrictEqual` and under any key-based comparison, while looking
* identical through `JSON`. `display("number", { unit: undefined })` — which
* `col.presets.duration()` produces whenever no unit is given — is the case
* that actually occurs.
*
* Exported for `infer.ts`, which builds descriptors outside the builder and so
* has to hold the same invariant.
*/
export function compact<T extends Record<string, unknown>>(object: T): T {
const result = {} as Record<string, unknown>;
for (const [key, value] of Object.entries(object)) {
if (value !== undefined) result[key] = value;
}
return result as T;
}
/**
* Strip caller-supplied `Option`s down to their serializable fields.
*
* `Option.value` is `string | boolean | number | undefined`, but an option with
* no value cannot be selected, cannot be encoded into filter state, and would
* vanish from the descriptor on `JSON.stringify` — so it is dropped here,
* at construction, rather than disappearing later at serialization time.
*/
function toOptionDescriptors(options: readonly Option[]): OptionDescriptor[] {
const result: OptionDescriptor[] = [];
for (const option of options) {
if (option.value === undefined) continue;
result.push({ label: option.label, value: option.value });
}
return result;
}
/** Store preset bounds as ISO instants so they survive JSON. */
function toPresetDescriptor(preset: DatePreset): DatePresetDescriptor {
return {
label: preset.label,
shortcut: preset.shortcut,
from: preset.from.toISOString(),
to: preset.to.toISOString(),
};
}
/** Revive a serialized preset back into the `DatePreset` the UI consumes. */
export function fromPresetDescriptor(
descriptor: DatePresetDescriptor,
): DatePreset {
return {
label: descriptor.label,
shortcut: descriptor.shortcut,
from: new Date(descriptor.from),
to: new Date(descriptor.to),
};
}
// ── The builder ─────────────────────────────────────────────────────────────
/**
* Build a `ColBuilder` over an explicit descriptor + renderers pair.
*
* Exported because `deserializeSchema` reconstructs builders by handing a
* descriptor straight back — no per-field replay of the fluent chain.
*/
export function createColBuilder<T, F extends FilterType = FilterType>(
descriptor: ColumnDescriptor,
renderers: ColRenderers = {},
): ColBuilder<T, F> {
// The implementation uses loose parameter types to satisfy all overload
// signatures at once. TypeScript enforces the constraints at call sites
// via the ColBuilder<T, F> interface overloads, not here.
const next = <U, G extends FilterType>(
patch: Partial<ColumnDescriptor>,
rendererPatch?: ColRenderers,
): ColBuilder<U, G> =>
createColBuilder<U, G>(
{ ...descriptor, ...patch } as ColumnDescriptor,
rendererPatch ? compact({ ...renderers, ...rendererPatch }) : renderers,
);
/** Return `renderers` without the named closures. */
const withoutRenderers = (...keys: (keyof ColRenderers)[]): ColRenderers => {
const result = { ...renderers };
for (const key of keys) delete result[key];
return result;
};
const builder = {
get _descriptor() {
return descriptor;
},
get _renderers() {
return renderers;
},
label(text: string): ColBuilder<T, F> {
return next<T, F>({ label: text });
},
description(text: string): ColBuilder<T, F> {
return next<T, F>({ description: text });
},
display(type: string, options?: Record<string, unknown>): ColBuilder<T, F> {
// "custom" is not a descriptor state — the closure goes to the renderers
// half and the descriptor keeps whatever serializable display it had, so
// it is already the correct fallback for the sheet and for toJSON().
if (type === "custom") {
const { cell, colorMap } = options as {
cell: ColRenderers["cell"];
colorMap?: Record<string, string>;
};
return next<T, F>(
colorMap
? {
display: {
...descriptor.display,
colorMap,
} as DisplayDescriptor,
}
: {},
{ cell },
);
}
const display = compact(
options ? { type, ...options } : { type },
) as DisplayDescriptor;
return next<T, F>({ display });
},
filterable(
type?: string,
options?: Record<string, unknown>,
): ColBuilder<T, F> {
const filterType = (type ||
descriptor.filter?.type ||
"input") as FilterType;
const existing = descriptor.filter;
const sameType = filterType === existing?.type;
const {
component,
options: rawOptions,
presets: rawPresets,
...rest
} = (options ?? {}) as {
component?: ColRenderers["filterComponent"];
options?: Option[];
presets?: DatePreset[];
min?: number;
max?: number;
unit?: string;
};
const filter: FilterDescriptor = {
type: filterType,
defaultOpen: existing?.defaultOpen ?? false,
commandDisabled: existing?.commandDisabled ?? false,
// Preserve auto-derived options when the filter type stays the same
// and no explicit options are provided by the caller.
...(sameType && existing?.options ? { options: existing.options } : {}),
...compact(rest),
...(rawOptions ? { options: toOptionDescriptors(rawOptions) } : {}),
...(rawPresets
? { presets: rawPresets.map(toPresetDescriptor) }
: sameType && existing?.presets
? { presets: existing.presets }
: {}),
};
return next<T, F>(
{ filter },
component ? { filterComponent: component } : undefined,
);
},
notFilterable(): ColBuilder<T, never> {
return createColBuilder<T, never>(
{ ...descriptor, filter: null },
// The filter component is meaningless without a filter.
withoutRenderers("filterComponent"),
);
},
defaultOpen(): ColBuilder<T, F> {
if (!descriptor.filter) return next<T, F>({});
return next<T, F>({
filter: { ...descriptor.filter, defaultOpen: true },
});
},
commandDisabled(): ColBuilder<T, F> {
if (!descriptor.filter) return next<T, F>({});
return next<T, F>({
filter: { ...descriptor.filter, commandDisabled: true },
});
},
hidden(): ColBuilder<T, F> {
return next<T, F>({ hidden: true });
},
hideHeader(): ColBuilder<T, F> {
return next<T, F>({ hideHeader: true });
},
resizable(): ColBuilder<T, F> {
return next<T, F>({ resizable: true });
},
size(px: number): ColBuilder<T, F> {
return next<T, F>({ size: px });
},
minSize(px: number): ColBuilder<T, F> {
return next<T, F>({ minSize: px });
},
/**
* Click-to-sort on the column header. Pass `false` to switch sorting off
* again on a preset that enables it (`timestamp`, `latency`, …).
*/
sortable(enabled = true): ColBuilder<T, F> {
return next<T, F>({ sortable: enabled });
},
optional(): ColBuilder<T | undefined, F> {
return next<T | undefined, F>({ optional: true });
},
sheet(sheetConfig?: SheetConfig): ColBuilder<T, F> {
const { component, condition, ...sheetDescriptor } = sheetConfig ?? {};
return next<T, F>(
{ sheet: compact(sheetDescriptor) },
compact({ sheetComponent: component, sheetCondition: condition }),
);
},
sheetOnly(): ColBuilder<T, never> {
return createColBuilder<T, never>(
{
...descriptor,
hidden: true,
filter: null,
enableHiding: false,
},
withoutRenderers("filterComponent"),
);
},
} as ColBuilder<T, F>;
return builder;
}
/**
* Attach provenance to a builder.
*
* Internal to this package — `col.presets.*` records which preset produced the
* column and with what arguments, and `infer.ts` records which heuristic fired.
* `schemaToTypeScript` reads this instead of pattern-matching the descriptor.
*/
export function withProvenance<T, F extends FilterType>(
builder: ColBuilder<T, F>,
provenance: Provenance,
): ColBuilder<T, F> {
return createColBuilder<T, F>(
{ ...builder._descriptor, provenance },
builder._renderers,
);
}
// ── The public read model ───────────────────────────────────────────────────
/**
* Read a column's complete state — both halves — through one public accessor.
*
* Prefer this over reaching into `_descriptor` / `_renderers`, which are
* `@internal` and may change shape.
*/
// `any` on the filter-type parameter: callers hold builders of every filter
// type at once, and the read model does not depend on which one it is.
export function resolveColumn(
builder: ColBuilder<unknown, any>,
): ResolvedColumn {
return { ...builder._descriptor, renderers: builder._renderers };
}
/** `resolveColumn` over a whole definition, preserving key insertion order. */
export function resolveColumns(
definition: TableSchemaDefinition,
): ResolvedColumnEntry[] {
return Object.entries(definition).map(([key, builder]) => ({
key,
...resolveColumn(builder),
}));
}
// ── Factories ───────────────────────────────────────────────────────────────
/** The descriptor every `col.*` factory starts from, before kind-specific bits. */
function base(kind: ColKind): ColumnDescriptorCommon {
return {
optional: false,
label: "",
display: defaultDisplayForKind(kind),
hidden: false,
enableHiding: true,
hideHeader: false,
resizable: false,
sortable: false,
filter: null,
sheet: null,
provenance: MANUAL,
};
}
/**
* A string column.
*
* - Data type: `string`
* - Default display: `"text"` (plain text with overflow tooltip)
* - Default filter: `"input"` (text search)
* - Allowed filters: `"input"`
*
* @example
* col.string().label("Host").size(125).sheet()
* col.string().label("Message").notFilterable().optional().hidden()
*/
function string(): ColBuilder<string, "input"> {
return createColBuilder<string, "input">({
...base("string"),
kind: "string",
filter: { type: "input", defaultOpen: false, commandDisabled: false },
});
}
/**
* A numeric column.
*
* - Data type: `number`
* - Default display: `"number"` (formatted, with optional unit)
* - Default filter: `"input"` (exact match)
* - Allowed filters: `"input"` | `"slider"` | `"checkbox"`
* - Use `"slider"` for continuous values (latency, file size)
* - Use `"checkbox"` for discrete values (HTTP status codes, port numbers)
*
* @example
* col.number().label("Latency").display("number", { unit: "ms" }).filterable("slider", { min: 0, max: 5000 }).sortable()
* col.number().label("Status").filterable("checkbox", { options: [{ label: "200", value: 200 }] })
*/
function number(): ColBuilder<number, "input" | "slider" | "checkbox"> {
return createColBuilder<number, "input" | "slider" | "checkbox">({
...base("number"),
kind: "number",
filter: { type: "input", defaultOpen: false, commandDisabled: false },
});
}
/**
* A boolean column.
*
* - Data type: `boolean`
* - Default display: `"boolean"` (checkmark / dash icon)
* - Default filter: `"checkbox"` with `true` / `false` options pre-wired
* - Allowed filters: `"checkbox"`
*
* @example
* col.boolean().label("Cache Hit").defaultOpen()
*/
function boolean(): ColBuilder<boolean, "checkbox"> {
return createColBuilder<boolean, "checkbox">({
...base("boolean"),
kind: "boolean",
filter: {
type: "checkbox",
defaultOpen: false,
commandDisabled: false,
options: [
{ label: "true", value: true },
{ label: "false", value: false },
],
},
});
}
/**
* A timestamp column.
*
* - Data type: `Date`
* - Default display: `"timestamp"` (relative time, absolute datetime on hover)
* - Default filter: `"timerange"` (date range picker)
* - Allowed filters: `"timerange"`
*
* @example
* col.timestamp().label("Date").sortable().commandDisabled().size(200).sheet()
*/
function timestamp(): ColBuilder<Date, "timerange"> {
return createColBuilder<Date, "timerange">({
...base("timestamp"),
kind: "timestamp",
filter: { type: "timerange", defaultOpen: false, commandDisabled: true },
});
}
/**
* An enum column from a `readonly string[]` union.
*
* - Data type: `T[number]` (union of the provided string literals)
* - Default display: `"badge"` (colored chip)
* - Default filter: `"checkbox"` with options auto-derived from `values`
* - Allowed filters: `"checkbox"`
*
* Checkbox options are auto-derived from `values` by default. Override them via
* `.filterable("checkbox", { options: [...] })` when you need custom labels,
* icons, or a subset of values.
*
* @param values - `as const` array of allowed string values
*
* @example
* col.enum(LEVELS).label("Level").defaultOpen()
* col.enum(LEVELS).label("Level").filterable("checkbox", {
* options: LEVELS.map(v => ({ label: v, value: v })), // override labels
* })
*/
function colEnum<T extends readonly string[]>(
values: T,
): ColBuilder<T[number], "checkbox"> {
return createColBuilder<T[number], "checkbox">({
...base("enum"),
kind: "enum",
enumValues: values,
filter: {
type: "checkbox",
defaultOpen: false,
commandDisabled: false,
options: Array.from(values).map((v) => ({ label: v, value: v })),
},
});
}
/**
* An array column, typically used for multi-value enum fields.
*
* - Data type: `U[]` where `U` is the item builder's type
* - Default display: `"badge"` (colored chip per value)
* - Default filter: `"checkbox"`
* - Allowed filters: `"checkbox"`
*
* Most commonly used as `col.array(col.enum(values))` for tags / regions / labels.
* `col.array(col.string())` is equally valid — the item type is recorded, so a
* `string[]` column stays a `string[]` column across a JSON round trip.
*
* @param itemBuilder - A `ColBuilder` describing the array item type
*
* @example
* col.array(col.enum(REGIONS)).label("Regions").filterable("checkbox", {
* options: REGIONS.map(r => ({ label: r, value: r })),
* })
*/
function array<U>(
itemBuilder: ColBuilder<U, any>,
): ColBuilder<U[], "checkbox"> {
return createColBuilder<U[], "checkbox">({
...base("array"),
kind: "array",
arrayItem: itemBuilder._descriptor,
filter: { type: "checkbox", defaultOpen: false, commandDisabled: false },
});
}
/**
* A key-value record column.
*
* - Data type: `Record<string, string>`
* - Default display: `"text"`
* - Not filterable (`F = never`)
*
* Use for metadata maps, HTTP headers, environment variables, etc.
* Typically rendered with a custom sheet component (key-value table / tabs).
*
* @example
* col.record().label("Headers").hidden().sheet({
* component: (row) => <KVTabs data={row.headers} />,
* className: "flex-col items-start w-full gap-1",
* })
*/
function record(): ColBuilder<Record<string, string>, never> {
return createColBuilder<Record<string, string>, never>({
...base("record"),
kind: "record",
});
}
/**
* A row-selection checkbox column.
*
* - Data type: `boolean` (selected state)
* - Not filterable (`F = never`)
* - Not shown in sheet or filters
* - Renders a checkbox in both the header (select all) and each row
*
* @example
* col.select().label("Select")
*/
function select(): ColBuilder<boolean, never> {
return createColBuilder<boolean, never>({
...base("select"),
kind: "select",
label: "Select",
enableHiding: false,
size: 40,
});
}
export const col = {
string,
number,
boolean,
timestamp,
enum: colEnum,
array,
record,
select,
};
src/lib/table-schema/presets.ts
import { col, withProvenance } from "./col";
import type { ColBuilder, FilterType, JsonValue } from "./types";
/**
* Tag a preset-built column so `schemaToTypeScript` can re-emit the exact call
* instead of reverse-engineering it from the descriptor's shape.
*/
function tag<T, F extends FilterType>(
preset: string,
args: readonly (JsonValue | undefined)[],
builder: ColBuilder<T, F>,
): ColBuilder<T, F> {
// Trailing omitted args are dropped so the re-emitted call matches what the
// author wrote: `duration("ms")`, not `duration("ms", undefined)`. An omitted
// arg in the *middle* becomes `null`, which the emitter prints as `undefined`
// — `undefined` inside a JSON array does not survive `JSON.stringify`.
const trimmed = [...args];
while (trimmed.length > 0 && trimmed[trimmed.length - 1] === undefined) {
trimmed.pop();
}
return withProvenance(builder, {
source: "preset",
preset,
args: trimmed.map((a) => (a === undefined ? null : a)),
});
}
const DEFAULT_HTTP_STATUS_CODES = [
200, 201, 204, 301, 302, 400, 401, 403, 404, 422, 429, 500, 502, 503, 504,
];
/**
* Pre-configured column builders for patterns common in log and observability tables.
*
* Every preset returns a `ColBuilder` with sensible defaults already applied.
* All builders remain fully customizable — chain additional methods to override
* any default (label, size, display, sheet, etc.).
*
* @example
* ```ts
* const tableSchema = createTableSchema({
* level: col.presets.logLevel(LEVELS).description("Log severity"),
* date: col.presets.timestamp().label("Date").size(200).sheet(),
* latency: col.presets.duration("ms").label("Latency").sortable().size(110).sheet(),
* status: col.presets.httpStatus().label("Status").size(60),
* method: col.presets.httpMethod(METHODS).size(69),
* path: col.presets.pathname().label("Path").size(130).sheet(),
* traceId: col.presets.traceId().label("Request ID").hidden().sheet(),
* });
* ```
*/
export const presets = {
/**
* A log severity level column.
*
* Defaults: enum + badge display + checkbox filter + `defaultOpen`.
* Checkbox options are auto-derived from `values` — no need to map them manually.
*
* @param values - The allowed severity levels, e.g. `["error", "warn", "info", "debug"] as const`
*
* @example
* ```ts
* col.presets.logLevel(LEVELS)
* .label("Level")
* .description("Log severity: error > warn > info > debug")
* .size(27)
* ```
*/
logLevel<T extends readonly string[]>(
values: T,
): ColBuilder<T[number], "checkbox"> {
return tag(
"logLevel",
[values as readonly string[]],
col
.enum(values)
.label("Level")
.filterable("checkbox", {
options: values.map((v) => ({ label: v, value: v })),
})
.defaultOpen(),
);
},
/**
* An HTTP method column.
*
* Defaults: enum + plain text display + checkbox filter.
* Options are auto-derived from `values`.
*
* @param values - The allowed HTTP methods, e.g. `["GET", "POST", "PUT", "DELETE"] as const`
*
* @example
* ```ts
* col.presets.httpMethod(METHODS).size(69)
* ```
*/
httpMethod<T extends readonly string[]>(
values: T,
): ColBuilder<T[number], "checkbox"> {
return tag(
"httpMethod",
[values as readonly string[]],
col
.enum(values)
.label("Method")
.display("text")
.filterable("checkbox", {
options: values.map((v) => ({ label: v, value: v })),
}),
);
},
/**
* An HTTP status code column.
*
* Defaults: number + checkbox filter with a standard set of common status codes.
* Pass a custom `codes` array to override the defaults.
*
* Default codes: 200, 201, 204, 301, 302, 400, 401, 403, 404, 422, 429, 500, 502, 503, 504
*
* @param codes - Override the default status code options
*
* @example
* ```ts
* col.presets.httpStatus().label("Status").size(60)
* col.presets.httpStatus([200, 400, 500]).label("Status") // custom codes
* ```
*/
httpStatus(
codes?: number[],
): ColBuilder<number, "input" | "slider" | "checkbox"> {
return tag(
"httpStatus",
[codes as readonly number[] | undefined],
col
.number()
.label("Status")
.filterable("checkbox", {
options: (codes ?? DEFAULT_HTTP_STATUS_CODES).map((code) => ({
label: String(code),
value: code,
})),
}),
);
},
/**
* A duration / latency / timing column.
*
* Defaults: number + formatted number display with unit + slider filter
* with bounds `{ min: 0, max: 5000 }`.
*
* @param unit - Unit label shown after the value, e.g. `"ms"`, `"s"`, `"µs"`
* @param slider - Override the slider bounds (default: `{ min: 0, max: 5000 }`)
*
* @example
* ```ts
* col.presets.duration("ms").label("Latency").sortable().size(110).sheet()
* col.presets.duration("s", { min: 0, max: 60 }).label("Response time")
* ```
*/
duration(
unit?: string,
slider?: { min: number; max: number },
): ColBuilder<number, "input" | "slider" | "checkbox"> {
return tag(
"duration",
[unit, slider],
col
.number()
.label("Duration")
.display("number", { unit })
.filterable("slider", slider ?? { min: 0, max: 5000 }),
);
},
/**
* A timestamp column.
*
* Defaults: Date + relative timestamp display (absolute on hover) +
* timerange filter + sortable.
*
* @example
* ```ts
* col.presets.timestamp().label("Date").commandDisabled().size(200).sheet()
* ```
*/
timestamp(): ColBuilder<Date, "timerange"> {
return tag(
"timestamp",
[],
col.timestamp().label("Timestamp").display("timestamp").sortable(),
);
},
/**
* A trace / span / request ID column.
*
* Defaults: string + monospace code display + not filterable.
* Typically hidden in the table and shown only in the row detail drawer.
*
* @example
* ```ts
* col.presets.traceId().label("Request ID").hidden().sheet({ skeletonClassName: "w-64" })
* ```
*/
traceId(): ColBuilder<string, never> {
return tag(
"traceId",
[],
col.string().label("Trace ID").display("code").notFilterable(),
);
},
/**
* A URL pathname column.
*
* Defaults: string + plain text display + input (text search) filter.
*
* @example
* ```ts
* col.presets.pathname().size(130).sheet()
* ```
*/
pathname(): ColBuilder<string, "input"> {
return tag(
"pathname",
[],
col.string().label("Pathname").filterable("input"),
);
},
/**
* A latency column with heatmap visualization.
*
* Defaults: number + heatmap display with unit + slider filter
* with bounds `{ min: 0, max: 5000 }` + sortable.
*
* @param unit - Unit label shown after the value, e.g. `"ms"`, `"s"`, `"µs"`
* @param slider - Override the slider bounds (default: `{ min: 0, max: 5000 }`)
*
* @example
* ```ts
* col.presets.latency("ms").label("Latency").size(110).sheet()
* col.presets.latency("s", { min: 0, max: 60 }).label("Response time")
* ```
*/
latency(
unit?: string,
slider?: { min: number; max: number },
): ColBuilder<number, "input" | "slider" | "checkbox"> {
const bounds = slider ?? { min: 0, max: 5000 };
return tag(
"latency",
[unit, slider],
col
.number()
.label("Latency")
.display("heatmap", { unit, min: bounds.min, max: bounds.max })
.filterable("slider", bounds)
.sortable(),
);
},
/**
* A health/score column with gauge visualization.
*
* Defaults: number + gauge display (0–100) + sortable.
*
* @param range - Override the min/max range (default: `{ min: 0, max: 100 }`)
*
* @example
* ```ts
* col.presets.health().label("Health")
* col.presets.health({ min: 0, max: 1000 }).label("Score")
* ```
*/
health(range?: {
min: number;
max: number;
}): ColBuilder<number, "input" | "slider" | "checkbox"> {
const { min = 0, max = 100 } = range ?? {};
return tag(
"health",
[range],
col.number().label("Health").display("gauge", { min, max }).sortable(),
);
},
/**
* A progress column with bar visualization.
*
* Defaults: number + bar display (0–100) + sortable.
*
* @param range - Override the min/max range (default: `{ min: 0, max: 100 }`)
*
* @example
* ```ts
* col.presets.progress().label("Progress")
* col.presets.progress({ min: 0, max: 1000 }).label("Completion")
* ```
*/
progress(range?: {
min: number;
max: number;
}): ColBuilder<number, "input" | "slider" | "checkbox"> {
const { min = 0, max = 100 } = range ?? {};
return tag(
"progress",
[range],
col.number().label("Progress").display("bar", { min, max }).sortable(),
);
},
};
src/lib/table-schema/validate.ts
import { resolveColumns } from "./col";
import type { TableSchemaDefinition } from "./types";
/**
* Validates a table schema definition and throws a descriptive error on the
* first violation found.
*
* Called automatically by `createTableSchema()` — no need to call manually.
*
* Catches errors that the TypeScript type system cannot prevent:
* - Missing label (`.label()` was never called)
* - Slider `min` greater than `max`
*
* These checks run for both the TypeScript-authored path (`createTableSchema({...})`)
* and the AI-generated path (`createTableSchema.fromJSON(json)`).
*/
export function validateSchema(definition: TableSchemaDefinition): void {
for (const c of resolveColumns(definition)) {
const { key } = c;
// 1. Label is required — col.* factories default to label: ""
if (!c.label) {
throw new Error(
`[createTableSchema] Column "${key}" is missing a label.\n` +
` Fix: .label("${key[0]!.toUpperCase()}${key.slice(1)}")`,
);
}
// 2. Number checkbox filter requires explicit options — the number factory
// has no value list to auto-derive from, so an empty options list would
// render a filter with no checkboxes (a silent no-op in the UI).
if (
c.kind === "number" &&
c.filter?.type === "checkbox" &&
(!c.filter.options || c.filter.options.length === 0)
) {
throw new Error(
`[createTableSchema] Column "${key}": checkbox filter on a number column requires explicit options.\n` +
` Fix: .filterable("checkbox", { options: [{ label: "200", value: 200 }, ...] })`,
);
}
// 3. Slider bounds must be valid — type system requires { min, max } to be
// passed but cannot enforce min < max
if (c.filter?.type === "slider") {
const { min, max } = c.filter;
if (min === undefined || max === undefined) {
throw new Error(
`[createTableSchema] Column "${key}": slider filter is missing min/max bounds.\n` +
` Fix: .filterable("slider", { min: 0, max: 100 })`,
);
}
if (min > max) {
throw new Error(
`[createTableSchema] Column "${key}": slider min (${min}) must be less than max (${max}).\n` +
` Fix: swap the values — .filterable("slider", { min: ${max}, max: ${min} })`,
);
}
}
// 4. A sheet-only column cannot carry a filter. `.sheetOnly()` is the only
// chain step that sets `enableHiding: false` on a hideable column, and
// it clears the filter, so no builder can produce this combination —
// only hand-written or AI-generated JSON can. It has no chain form,
// which means `schemaToTypeScript` would have to drop one half of it.
if (c.enableHiding === false && c.hidden && c.filter !== null) {
throw new Error(
`[createTableSchema] Column "${key}": a sheet-only column cannot have a filter.\n` +
` Fix: drop the filter (.sheetOnly() implies it), or make the column filterable and hidden instead.`,
);
}
}
}
src/lib/table-schema/serialize.ts
import { createColBuilder, defaultDisplayForKind } from "./col";
import type {
ColKind,
ColumnDescriptor,
DisplayDescriptor,
FilterDescriptor,
Provenance,
SchemaJSON,
SchemaJSONVersion,
SheetDescriptor,
TableSchemaDefinition,
} from "./types";
/** The current `SchemaJSON` version. Bump alongside a `migrateSchemaJSON` step. */
export const SCHEMA_JSON_VERSION: SchemaJSONVersion = 1;
// ── Serialization ───────────────────────────────────────────────────────────
//
// The descriptor IS the serializable state, so serialization is a rename of the
// key and nothing else. There is no projection to keep in sync, which is why
// `unit`, `presets`, and `resizable` can no longer be silently dropped.
export function serializeSchema(definition: TableSchemaDefinition): SchemaJSON {
return {
version: SCHEMA_JSON_VERSION,
columns: Object.entries(definition).map(([key, builder]) => ({
key,
...builder._descriptor,
})),
};
}
// ── Deserialization ─────────────────────────────────────────────────────────
//
// Descriptors are stored verbatim, so reconstruction hands the descriptor
// straight back to the builder. The old ~40-branch replay of the fluent chain
// — including the `enableHiding === false && hidden ⇒ .sheetOnly()` inference
// archaeology — is gone.
//
// Limitation, unchanged: renderers (display cell, filter component, sheet
// component/condition) are closures and cannot be serialized. A deserialized
// column falls back to its descriptor's display, which is why the descriptor
// keeps a real display type even when a custom renderer was supplied.
export function deserializeSchema(json: SchemaJSON): TableSchemaDefinition {
const definition: TableSchemaDefinition = {};
for (const { key, ...descriptor } of json.columns) {
definition[key] = createColBuilder(descriptor as ColumnDescriptor, {});
}
return definition;
}
// ── Migration ───────────────────────────────────────────────────────────────
const COL_KINDS: readonly ColKind[] = [
"string",
"number",
"boolean",
"timestamp",
"enum",
"array",
"record",
"select",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asKind(value: unknown, key: unknown): ColKind {
if (COL_KINDS.includes(value as ColKind)) return value as ColKind;
// Falling back is better than throwing — one bad column should not make a
// whole saved schema unloadable — but it is a real loss, so say so.
if (value !== undefined) {
console.warn(
`[migrateSchemaJSON] Column ${JSON.stringify(key)} has unknown kind ` +
`${JSON.stringify(value)}. Falling back to "string".`,
);
}
return "string";
}
/**
* Normalize an array column's item descriptor.
*
* v1 nests a full `ColumnDescriptor`; v0 carried only `{ dataType, enumValues }`
* under `arrayItemType` — and, for non-enum items, nothing at all, which is
* exactly how `string[]` columns used to collapse into `string` columns.
*/
function normalizeArrayItem(raw: unknown): ColumnDescriptor {
const { key: _key, ...item } = normalizeColumn(raw);
return item as ColumnDescriptor;
}
function isProvenance(value: unknown): value is Provenance {
if (!isRecord(value)) return false;
return (
value.source === "manual" ||
(value.source === "preset" &&
typeof value.preset === "string" &&
Array.isArray(value.args)) ||
(value.source === "inferred" && typeof value.rule === "string")
);
}
/**
* Normalize one column from either version into a complete `ColumnDescriptor`.
*
* v1 payloads go through this too rather than being trusted: `fromJSON` accepts
* user-typed text, so "it says version 1" is not evidence that its columns are
* well-formed. Every required field is filled or defaulted here, which is what
* makes the unchecked `as SchemaJSON` cast on untrusted input unnecessary.
*/
function normalizeColumn(raw: unknown): ColumnDescriptor & { key: string } {
const c = isRecord(raw) ? raw : {};
// v1 writes `kind`; v0 wrote `dataType`.
const kind = asKind(c.kind ?? c.dataType, c.key);
// v0 wrote `{ type: "text" }` for custom displays and omitted `unit`,
// `presets`, and `resizable` entirely. Nothing can recover those — the
// migration fills the type-required defaults and stops there.
const display = isRecord(c.display)
? (c.display as DisplayDescriptor)
: defaultDisplayForKind(kind);
// Carried through wholesale, like `display` and `sheet`. Whitelisting the
// optional fields here would reintroduce exactly the hand-maintained
// projection this split removed from `serializeSchema` — a new
// `FilterDescriptor` field would be dropped on migration and nothing would
// say so. Only the three required fields are coerced, because they must exist.
const filter: FilterDescriptor | null = isRecord(c.filter)
? {
...(c.filter as Partial<FilterDescriptor>),
type: (c.filter.type ?? "input") as FilterDescriptor["type"],
defaultOpen: c.filter.defaultOpen === true,
commandDisabled: c.filter.commandDisabled === true,
}
: null;
const sheet: SheetDescriptor | null = isRecord(c.sheet)
? (c.sheet as SheetDescriptor)
: null;
const common = {
label: typeof c.label === "string" ? c.label : "",
...(typeof c.description === "string"
? { description: c.description }
: {}),
optional: c.optional === true,
display,
...(typeof c.size === "number" ? { size: c.size } : {}),
...(typeof c.minSize === "number" ? { minSize: c.minSize } : {}),
hidden: c.hidden === true,
enableHiding: c.enableHiding !== false,
hideHeader: c.hideHeader === true,
// v0 had no `resizable` at all, so it defaults to false there; v1 writes it.
resizable: c.resizable === true,
sortable: c.sortable === true,
filter,
sheet,
provenance: (isProvenance(c.provenance)
? c.provenance
: { source: "manual" }) as Provenance,
};
const key = typeof c.key === "string" ? c.key : "";
if (kind === "enum") {
return {
key,
...common,
kind: "enum",
enumValues: Array.isArray(c.enumValues) ? (c.enumValues as string[]) : [],
};
}
if (kind === "array") {
return {
key,
...common,
kind: "array",
// v1 nests a full descriptor under `arrayItem`; v0 carried only
// `{ dataType, enumValues }` under `arrayItemType`.
arrayItem: normalizeArrayItem(c.arrayItem ?? c.arrayItemType),
};
}
return { key, ...common, kind } as ColumnDescriptor & { key: string };
}
/**
* Bring untrusted schema JSON up to the current version.
*
* `SchemaJSON` is persisted (the builder cache) and pasted by hand into the
* schema editor, so every entry point that accepts it must run through here
* first. This is the one hand-written conversion the descriptor split
* deliberately keeps.
*
* v0 (no `version` field) → v1: `dataType` → `kind`, `arrayItemType` →
* recursive `arrayItem`, and the newly-required `resizable`, `enableHiding`,
* `hideHeader`, and `provenance` get their defaults.
*
* @throws if `json` is not an object with a `columns` array.
*/
export function migrateSchemaJSON(json: unknown): SchemaJSON {
if (!isRecord(json) || !Array.isArray(json.columns)) {
throw new Error(
"[migrateSchemaJSON] Expected an object with a `columns` array.",
);
}
if (json.version !== undefined && json.version !== SCHEMA_JSON_VERSION) {
throw new Error(
`[migrateSchemaJSON] Unknown schema version ${JSON.stringify(json.version)}. ` +
`This build understands version ${SCHEMA_JSON_VERSION}.`,
);
}
return {
version: SCHEMA_JSON_VERSION,
columns: json.columns.map(normalizeColumn),
};
}
src/lib/table-schema/manifest.ts
import type { ActionDescriptor } from "@/lib/actions/types";
import { sanitizeActionDescriptors } from "@/lib/actions/validate";
import type { ActionValidationOptions } from "@/lib/actions/validate";
import { migrateSchemaJSON } from "./serialize";
import type { SchemaJSON } from "./types";
/**
* The table manifest — what an endpoint says about itself.
*
* This is the missing half of the headless story. The schema was already
* serializable, but every consumer built it locally from TypeScript, so
* "point the table at a URL" was never actually possible. A manifest is that
* URL's answer: the schema, which column identifies a row, what the server can
* and cannot compute, and what may be done to a row.
*
* It is fetched over the network from a server the app may not own, so
* `parseTableManifest` treats every field as untrusted and bounded.
*/
/** Bump alongside a migration step in `parseTableManifest`. */
export const TABLE_MANIFEST_VERSION = 1;
/**
* What the endpoint can compute.
*
* The client used to assume all of it. An endpoint that cannot group facets or
* count matching rows had no way to say so, and the UI had no way to degrade —
* it simply rendered empty filters and a blank count. Each flag here is a thing
* the table will stop asking for, or compute on the client instead.
*/
export type TableCapabilities = {
/** `meta.facets` is populated. When false, facet counts come from loaded rows. */
facets: boolean;
/**
* The columns the server can facet, when it can only do some. Omitted means
* "every filterable column".
*/
facetedColumns?: readonly string[];
/** `meta.totalRowCount` is meaningful. */
totalRowCount: boolean;
/** `meta.filterRowCount` is meaningful. */
filterRowCount: boolean;
/** `meta.chartData` is populated. When false, the chart slot is not rendered. */
chart: boolean;
/** `prevCursor` is honoured — the precondition for live mode. */
backwardPagination: boolean;
/** `meta.actions` and the per-row `_actions` stamp are populated. */
actions: boolean;
};
/**
* The conservative reading of an endpoint that said nothing.
*
* Everything optional is off: a table that renders no chart against a server
* that has one is a missing feature, while a table that renders a chart against
* a server that has none is a broken screen.
*/
export const DEFAULT_CAPABILITIES: TableCapabilities = {
facets: false,
totalRowCount: false,
filterRowCount: false,
chart: false,
backwardPagination: false,
actions: false,
};
/**
* How the timeline chart is drawn.
*
* `meta.chartData` is `{ timestamp, [series]: number }[]`, and until now which
* column it bucketed, which series it carried, and what colour each series took
* were all decided in app code. A table pointed at an endpoint has none of that,
* so the endpoint states it.
*/
export type TableChartConfig = {
/** The timestamp column the buckets are over. Must be a column in the schema. */
columnKey: string;
/** The numeric series in each point, in stacking order. */
series: { key: string; label?: string; color?: string }[];
/** Bucket width in milliseconds, when the endpoint has a fixed one. */
intervalMs?: number;
};
export type TableManifestDefaults = {
sort?: { id: string; desc: boolean };
size?: number;
columnVisibility?: Record<string, boolean>;
};
export type TableManifest = {
version: number;
schema: SchemaJSON;
/**
* The column that identifies a row on the wire.
*
* Actions key rows by it and the sheet resolves the open row through it. It
* used to be a closure written by hand at each call site
* (`getRowId={(row) => row.uuid}`), which is exactly the kind of thing a
* pointed-at table cannot supply.
*/
primaryKey: string;
/**
* A human name for a row, as a template over column keys:
* `"{method} {pathname}"`. Used for accessible labels, never for identity.
*/
rowLabel?: string;
capabilities: TableCapabilities;
/** Present when `capabilities.chart` is true. */
chart?: TableChartConfig;
actions?: ActionDescriptor[];
defaults?: TableManifestDefaults;
};
/**
* Build a manifest from a table schema.
*
* The server side of the contract. `createTableSchema(...)` already produces
* the `SchemaJSON`; this names the row identity and states what the endpoint
* can actually compute, which are the two things the schema alone never said.
*/
export function createTableManifest(config: {
schema: { toJSON(): SchemaJSON } | SchemaJSON;
primaryKey: string;
rowLabel?: string;
capabilities?: Partial<TableCapabilities>;
chart?: TableChartConfig;
actions?: ActionDescriptor[];
defaults?: TableManifestDefaults;
}): TableManifest {
const schema =
"toJSON" in config.schema ? config.schema.toJSON() : config.schema;
if (!schema.columns.some((column) => column.key === config.primaryKey)) {
// Caught here rather than at the client, which would only be able to blank
// the table and say the endpoint was wrong.
throw new TableManifestError(
`primaryKey ${JSON.stringify(config.primaryKey)} is not a column in the schema`,
);
}
return {
version: TABLE_MANIFEST_VERSION,
schema,
primaryKey: config.primaryKey,
...(config.rowLabel ? { rowLabel: config.rowLabel } : {}),
capabilities: { ...DEFAULT_CAPABILITIES, ...config.capabilities },
...(config.chart ? { chart: config.chart } : {}),
...(config.actions?.length ? { actions: config.actions } : {}),
...(config.defaults ? { defaults: config.defaults } : {}),
};
}
// ── Bounds ──────────────────────────────────────────────────────────────────
/**
* Caps on an incoming manifest.
*
* A remote schema drives column generation, filter rendering and URL state, so
* an unbounded one is a way to hang the tab. These are deliberately far above
* any real table.
*/
export const MANIFEST_LIMITS = {
maxColumns: 200,
maxKeyLength: 200,
maxLabelLength: 500,
maxActions: 50,
} as const;
/**
* Path segments that reach an object's prototype rather than its own data.
*
* Column keys are server-authored. `definition[key] = builder` with a
* `"__proto__"` key does not add an own property — it reassigns the object's
* prototype, so the column silently vanishes from every `Object.keys` consumer
* with none of the warnings every other rejected input here produces. As a
* `primaryKey` it is worse: `getRowId` returns `"[object Object]"` for every
* row, collapsing selection and bulk actions onto a single id.
*/
const RESERVED_SEGMENTS: ReadonlySet<string> = new Set([
"__proto__",
"constructor",
"prototype",
]);
/** True when every segment of a dotted key addresses own data. */
export function isSafeColumnKey(key: string): boolean {
return key.split(".").every((segment) => !RESERVED_SEGMENTS.has(segment));
}
export class TableManifestError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "TableManifestError";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseCapabilities(value: unknown): TableCapabilities {
if (!isRecord(value)) return { ...DEFAULT_CAPABILITIES };
const facetedColumns = Array.isArray(value.facetedColumns)
? value.facetedColumns.filter(
(key): key is string =>
typeof key === "string" && key.length <= MANIFEST_LIMITS.maxKeyLength,
)
: undefined;
return {
facets: value.facets === true,
...(facetedColumns ? { facetedColumns } : {}),
totalRowCount: value.totalRowCount === true,
filterRowCount: value.filterRowCount === true,
chart: value.chart === true,
backwardPagination: value.backwardPagination === true,
actions: value.actions === true,
};
}
function parseDefaults(
value: unknown,
keys: ReadonlySet<string>,
): TableManifestDefaults | undefined {
if (!isRecord(value)) return undefined;
const defaults: TableManifestDefaults = {};
if (isRecord(value.sort) && typeof value.sort.id === "string") {
// A default sort on a column that does not exist would be written straight
// into the URL and then fail to resolve against the table.
if (keys.has(value.sort.id)) {
defaults.sort = { id: value.sort.id, desc: value.sort.desc === true };
}
}
if (
typeof value.size === "number" &&
Number.isInteger(value.size) &&
value.size > 0
) {
defaults.size = value.size;
}
if (isRecord(value.columnVisibility)) {
const visibility: Record<string, boolean> = {};
for (const [key, visible] of Object.entries(value.columnVisibility)) {
if (keys.has(key) && typeof visible === "boolean") {
visibility[key] = visible;
}
}
if (Object.keys(visibility).length > 0) {
defaults.columnVisibility = visibility;
}
}
return Object.keys(defaults).length > 0 ? defaults : undefined;
}
function parseChart(
value: unknown,
keys: ReadonlySet<string>,
warn: (message: string) => void,
): TableChartConfig | undefined {
if (!isRecord(value)) return undefined;
if (typeof value.columnKey !== "string" || !keys.has(value.columnKey)) {
// Without a column to bucket over there is nothing to draw, and a chart
// pinned to a column that no longer exists is worse than no chart.
warn("Dropped the chart config: `columnKey` names no column in the schema");
return undefined;
}
const series = (Array.isArray(value.series) ? value.series : [])
.filter(
(item): item is Record<string, unknown> =>
isRecord(item) &&
typeof item.key === "string" &&
item.key.length > 0 &&
item.key.length <= MANIFEST_LIMITS.maxKeyLength,
)
.slice(0, MANIFEST_LIMITS.maxColumns)
.map((item) => ({
key: item.key as string,
...(typeof item.label === "string" ? { label: item.label } : {}),
...(typeof item.color === "string" ? { color: item.color } : {}),
}));
if (series.length === 0) {
warn("Dropped the chart config: it declares no series");
return undefined;
}
return {
columnKey: value.columnKey,
series,
...(typeof value.intervalMs === "number" && value.intervalMs > 0
? { intervalMs: value.intervalMs }
: {}),
};
}
export type ParseManifestOptions = {
/** Passed to the action validator — see `sanitizeActionDescriptors`. */
actionValidation?: ActionValidationOptions;
/** Called with anything dropped, so a host can log it. */
onWarning?: (message: string) => void;
};
/**
* Validate and normalize an untrusted manifest.
*
* Throws only for the failures that leave nothing renderable — a missing
* schema, no columns, no usable primary key. Everything else is repaired or
* dropped with a warning, because one bad column should not blank the table.
*/
export function parseTableManifest(
value: unknown,
options?: ParseManifestOptions,
): TableManifest {
const warn = options?.onWarning ?? (() => {});
if (!isRecord(value)) {
throw new TableManifestError("Manifest is not an object");
}
// `migrateSchemaJSON` already normalizes every descriptor field and brings
// older versions forward, so this only has to add the bounds it does not. Its
// own rejection is re-thrown as a `TableManifestError` so that a caller has
// one error type to catch for "this endpoint did not answer with a manifest".
let schema: SchemaJSON;
try {
schema = migrateSchemaJSON(value.schema);
} catch (cause) {
throw new TableManifestError(
`Manifest schema is not usable: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
);
}
if (schema.columns.length === 0) {
throw new TableManifestError("Manifest schema has no columns");
}
if (schema.columns.length > MANIFEST_LIMITS.maxColumns) {
throw new TableManifestError(
`Manifest schema has ${schema.columns.length} columns, over the limit of ${MANIFEST_LIMITS.maxColumns}`,
);
}
const seenKeys = new Set<string>();
const columns = schema.columns.filter((column) => {
if (column.key.length === 0) {
warn("Dropped a column with an empty key");
return false;
}
if (column.key.length > MANIFEST_LIMITS.maxKeyLength) {
warn(`Dropped column ${JSON.stringify(column.key)}: key too long`);
return false;
}
if (!isSafeColumnKey(column.key)) {
warn(`Dropped column ${JSON.stringify(column.key)}: reserved key`);
return false;
}
// An empty label is not merely ugly: `createTableSchema.fromJSON` rejects
// it, which throws inside the component and blanks the whole table.
if (column.label.length === 0) {
warn(`Dropped column ${JSON.stringify(column.key)}: empty label`);
return false;
}
if (column.label.length > MANIFEST_LIMITS.maxLabelLength) {
warn(`Dropped column ${JSON.stringify(column.key)}: label too long`);
return false;
}
// The definition is keyed by column key, so a duplicate would silently
// overwrite the first with no diagnostic.
if (seenKeys.has(column.key)) {
warn(`Dropped column ${JSON.stringify(column.key)}: duplicate key`);
return false;
}
seenKeys.add(column.key);
return true;
});
if (columns.length === 0) {
throw new TableManifestError("Manifest schema has no usable columns");
}
const keys = new Set(columns.map((column) => column.key));
const primaryKey =
typeof value.primaryKey === "string" && keys.has(value.primaryKey)
? value.primaryKey
: null;
if (!primaryKey) {
throw new TableManifestError(
"Manifest primaryKey is missing or names a column that does not exist",
);
}
const capabilities = parseCapabilities(value.capabilities);
const { actions, rejected } = sanitizeActionDescriptors(
Array.isArray(value.actions)
? value.actions.slice(0, MANIFEST_LIMITS.maxActions)
: undefined,
options?.actionValidation,
);
for (const rejection of rejected) {
warn(`Dropped action ${rejection.id}: ${rejection.reason}`);
}
return {
version: TABLE_MANIFEST_VERSION,
schema: { version: schema.version, columns },
primaryKey,
...(typeof value.rowLabel === "string" &&
value.rowLabel.length <= MANIFEST_LIMITS.maxLabelLength
? { rowLabel: value.rowLabel }
: {}),
capabilities,
...(() => {
const chart = parseChart(value.chart, keys, warn);
return chart ? { chart } : {};
})(),
...(actions.length > 0 ? { actions } : {}),
...(() => {
const defaults = parseDefaults(value.defaults, keys);
return defaults ? { defaults } : {};
})(),
};
}
// ── ETag ────────────────────────────────────────────────────────────────────
/**
* FNV-1a over the serialized manifest.
*
* A schema changes on deploy, not per request, so the point of the ETag is to
* turn the client's revalidation into a 304 rather than a re-download. A
* non-cryptographic hash is the right tool: this guards cache freshness, not
* integrity.
*/
export function manifestETag(manifest: TableManifest): string {
const json = JSON.stringify(manifest);
let hash = 0x811c9dc5;
for (let i = 0; i < json.length; i++) {
hash ^= json.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return `"${hash.toString(16)}-${json.length.toString(16)}"`;
}
// ── Server ──────────────────────────────────────────────────────────────────
export type ManifestHandlerOptions = {
/**
* `Cache-Control` for the response. The default revalidates every time and
* relies on the ETag for the cheap path, which is the safe default for
* something that changes on deploy without a version in its URL.
*/
cacheControl?: string;
/** Extra response headers — CORS, most likely. */
headers?: Record<string, string>;
};
/**
* A `Request → Response` handler serving the manifest, with ETag revalidation.
*
* Pass a function rather than a value when the manifest depends on the request
* — per-tenant columns, or actions that depend on the caller's permissions.
*/
export function createTableManifestHandler(
manifest: TableManifest | ((request: Request) => TableManifest),
options?: ManifestHandlerOptions,
) {
return async function handler(request: Request): Promise<Response> {
const resolved =
typeof manifest === "function" ? manifest(request) : manifest;
const etag = manifestETag(resolved);
const headers: Record<string, string> = {
"content-type": "application/json",
etag,
"cache-control": options?.cacheControl ?? "no-cache",
...options?.headers,
};
// `If-None-Match` may carry a list, and a proxy may have weakened the tag.
const ifNoneMatch = request.headers.get("if-none-match");
if (ifNoneMatch) {
const candidates = ifNoneMatch
.split(",")
.map((value) => value.trim().replace(/^W\//, ""));
if (candidates.includes(etag) || candidates.includes("*")) {
return new Response(null, { status: 304, headers });
}
}
return new Response(JSON.stringify(resolved), { status: 200, headers });
};
}
// ── Client ──────────────────────────────────────────────────────────────────
export type FetchManifestOptions = ParseManifestOptions & {
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
credentials?: RequestCredentials;
fetch?: typeof fetch;
signal?: AbortSignal;
};
/**
* Fetch and validate a manifest.
*
* Deliberately not wired to React Query here — the manifest is needed *before*
* the query layer exists, because the URL-state adapter is built from it.
*/
export async function fetchTableManifest(
url: string,
options?: FetchManifestOptions,
): Promise<TableManifest> {
const doFetch = options?.fetch ?? fetch;
const headers =
typeof options?.headers === "function"
? await options.headers()
: options?.headers;
const response = await doFetch(url, {
headers,
credentials: options?.credentials,
signal: options?.signal,
});
if (!response.ok) {
throw new TableManifestError(
`Could not load the table manifest from ${url}: ${response.status} ${response.statusText}`.trim(),
);
}
let payload: unknown;
try {
payload = await response.json();
} catch (cause) {
throw new TableManifestError(
`The table manifest at ${url} is not valid JSON`,
{ cause },
);
}
return parseTableManifest(payload, options);
}
// ── Row accessors ───────────────────────────────────────────────────────────
/** Reads a possibly-dotted key path off a row. */
function readPath(row: unknown, path: string): unknown {
if (row === null || typeof row !== "object") return undefined;
if (!isSafeColumnKey(path)) return undefined;
const record = row as Record<string, unknown>;
// Rows may carry the dotted key literally — `createDrizzleHandler` projects
// `"timing.dns"` as a flat property — so the exact key wins before walking.
if (Object.hasOwn(record, path)) return record[path];
if (!path.includes(".")) return undefined;
let current: unknown = row;
for (const segment of path.split(".")) {
if (current === null || typeof current !== "object") return undefined;
// Own data only. `isSafeColumnKey` is a name list and cannot catch
// `"meta.toString"`, which otherwise resolves an inherited method — the
// same value on every row, collapsing every row id onto one.
if (!Object.hasOwn(current, segment)) return undefined;
current = (current as Record<string, unknown>)[segment];
}
return current;
}
export type RowAccessors<TRow> = {
/** The row's wire identity, from `manifest.primaryKey`. */
getRowId: (row: TRow) => string;
/**
* A human name for the row, from `manifest.rowLabel`. `undefined` when the
* manifest declared none — callers then fall back to static copy rather than
* reading an internal id out loud.
*/
getRowLabel?: (row: TRow) => string;
};
/**
* Derive `getRowId` / `getRowLabel` from a manifest.
*
* These were hand-written closures at every call site
* (`getRowId={(row) => row.uuid}`), which a table pointed at an endpoint cannot
* supply. `rowLabel` is a template over column keys — `"{method} {pathname}"` —
* with unknown keys left as written, so a typo is visible rather than silent.
*/
export function createRowAccessors<TRow = unknown>(
manifest: Pick<TableManifest, "primaryKey" | "rowLabel">,
): RowAccessors<TRow> {
const getRowId = (row: TRow): string => {
const value = readPath(row, manifest.primaryKey);
// Coerced rather than asserted: the id addresses a row in the DOM and on
// the wire, and a numeric primary key is perfectly ordinary.
return value === null || value === undefined ? "" : String(value);
};
const template = manifest.rowLabel;
if (!template) return { getRowId };
return {
getRowId,
getRowLabel: (row: TRow): string =>
template.replace(/\{([^{}]+)\}/g, (match, key: string) => {
const value = readPath(row, key.trim());
return value === null || value === undefined ? match : String(value);
}),
};
}
src/lib/table-schema/renderers.ts
import { createColBuilder } from "./col";
import { isSafeColumnKey } from "./manifest";
import type {
ColRenderers,
ColumnDescriptor,
TableSchemaDefinition,
} from "./types";
/**
* Attaching renderers to a schema that came over the wire.
*
* `serialize.ts` is explicit that the four renderers — the display cell, the
* filter component, the sheet component and its condition — are closures and
* cannot be serialized. A deserialized column therefore falls back to whatever
* named display its descriptor carries, which is why the descriptor keeps a
* real display type even when a custom renderer was originally supplied.
*
* The named displays (`badge`, `bar`, `status-code`, `level-indicator`, …)
* already cover most of what a table needs, and they travel as data. This
* module is the escape hatch for the rest: an app pointed at a remote schema
* supplies the handful of columns it wants to draw itself, by key, and keeps
* every other column's declarative rendering.
*
* @example
* ```ts
* const { definition } = createTableSchema.fromJSON(manifest.schema);
* const withRenderers = applyRenderers(definition, {
* pathname: { cell: (value) => <PathnameCell value={String(value)} /> },
* timing: { sheetComponent: (row) => <TimingPhases row={row} /> },
* });
* ```
*/
/** Renderer closures to attach, keyed by column key. */
export type RendererOverrides = Record<string, ColRenderers>;
export type ApplyRenderersOptions = {
/**
* Called for an override whose key is not in the schema.
*
* Worth listening to: a remote schema can drop a column between deploys, and
* an override left behind for it is silently dead. The default warns to the
* console rather than throwing, because one stale override should not blank
* a table.
*/
onUnknownKey?: (key: string) => void;
/**
* Called for a schema column whose key cannot be used.
*
* A separate channel from {@link onUnknownKey} because the two say opposite
* things: one means "your override names nothing", the other means "the
* schema's own column is unusable". Reporting a dropped column through the
* override warning sends the reader looking for a stale override that does
* not exist.
*/
onUnusableColumn?: (key: string) => void;
};
function defaultOnUnknownKey(key: string): void {
console.warn(
`[applyRenderers] no column ${JSON.stringify(key)} in the schema — ` +
`the override will not be used. Did the endpoint's schema change?`,
);
}
function defaultOnUnusableColumn(key: string): void {
console.warn(
`[applyRenderers] dropped schema column ${JSON.stringify(key)}: the key ` +
`reaches an object prototype, so it cannot address a column.`,
);
}
/**
* Return a copy of `definition` with the given renderers attached.
*
* Non-mutating: builders are immutable, so each overridden column is rebuilt
* over the same descriptor with the merged renderers. Columns with no override
* are passed through by reference.
*
* Only the renderer keys present in an override are set; passing
* `{ cell }` leaves an existing `sheetComponent` alone.
*/
export function applyRenderers(
definition: TableSchemaDefinition,
overrides: RendererOverrides,
options?: ApplyRenderersOptions,
): TableSchemaDefinition {
const onUnknownKey = options?.onUnknownKey ?? defaultOnUnknownKey;
const onUnusableColumn = options?.onUnusableColumn ?? defaultOnUnusableColumn;
const keys = Object.keys(overrides);
if (keys.length === 0) return definition;
for (const key of keys) {
// `in` walks the prototype chain, so `toString` would read as a column.
if (!Object.hasOwn(definition, key)) onUnknownKey(key);
}
const result: TableSchemaDefinition = {};
for (const [key, builder] of Object.entries(definition)) {
// Skipped before any assignment, not merely excluded from overrides:
// `result["__proto__"] = builder` invokes the setter and reassigns the
// result's prototype rather than adding a column, so the column vanishes
// from every `Object.keys` consumer with no diagnostic. Guarding only the
// override lookup still ran that assignment on the pass-through path.
if (!isSafeColumnKey(key)) {
onUnusableColumn(key);
continue;
}
const override = Object.hasOwn(overrides, key) ? overrides[key] : undefined;
if (!override) {
result[key] = builder;
continue;
}
result[key] = createColBuilder(
builder._descriptor as ColumnDescriptor,
// The descriptor is untouched: an override changes how a column draws,
// never what it is. `toJSON()` on the result still round-trips.
{ ...builder._renderers, ...override },
);
}
return result;
}
/**
* The display types a schema can name without shipping code.
*
* Exported so a tool — a schema builder UI, an agent writing a schema, a
* validator — can enumerate what is available declaratively before reaching
* for {@link applyRenderers}.
*/
export const NAMED_DISPLAY_TYPES = [
"text",
"code",
"boolean",
"star",
"badge",
"timestamp",
"number",
"bar",
"heatmap",
"gauge",
"status-code",
"level-indicator",
] as const;
export type NamedDisplayType = (typeof NAMED_DISPLAY_TYPES)[number];
/** Is this display type one the renderer can draw from the descriptor alone? */
export function isNamedDisplayType(type: string): type is NamedDisplayType {
return (NAMED_DISPLAY_TYPES as readonly string[]).includes(type);
}
src/lib/table-schema/snapshot.ts
import { parseTableManifest, type TableManifest } from "./manifest";
/**
* Freezing a manifest into the app at build time.
*
* Fetching the manifest at runtime is one round trip before the table can draw
* anything. A snapshot removes it: check the endpoint's answer into the repo,
* pass it as `initialManifest`, and the first paint has a schema while the
* query revalidates behind it.
*
* The trade is explicit rather than hidden. A snapshot is a copy, so it goes
* stale when the endpoint changes — which is exactly why `useTableManifest`
* still revalidates instead of trusting it forever, and why the generated file
* records where and when it came from.
*/
export type SnapshotOptions = {
/** The URL it came from, recorded in the header comment. */
source?: string;
/** Name of the exported const. Defaults to `manifest`. */
exportName?: string;
/**
* Import specifier for the `TableManifest` type. Defaults to the registry
* path; set it to wherever the schema block landed in the consuming app
* (`@/lib/table-schema`, most likely).
*/
typeImport?: string;
/**
* Timestamp for the header comment. Injectable so the output is
* deterministic in tests, and so a build can stamp its own commit time.
*/
generatedAt?: Date | string;
};
const DEFAULT_TYPE_IMPORT = "@/lib/table-schema";
/** A JS identifier, so the generated module actually parses. */
function assertIdentifier(name: string): void {
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
throw new Error(
`exportName ${JSON.stringify(name)} is not a valid identifier`,
);
}
}
/**
* Render a manifest as a TypeScript module.
*
* The manifest is re-parsed on the way in: a snapshot is generated from a live
* endpoint's response, so it goes through the same validation as one consumed
* at runtime. Freezing an invalid manifest into the repo would only move the
* failure to a place where nobody is looking for it.
*/
export function manifestToModule(
manifest: unknown,
options?: SnapshotOptions,
): string {
const exportName = options?.exportName ?? "manifest";
assertIdentifier(exportName);
const parsed: TableManifest = parseTableManifest(manifest);
const typeImport = options?.typeImport ?? DEFAULT_TYPE_IMPORT;
const generatedAt =
options?.generatedAt instanceof Date
? options.generatedAt.toISOString()
: options?.generatedAt;
const provenance = [
" * Generated by `manifestToModule`. Do not edit by hand.",
options?.source ? ` * Source: ${options.source}` : null,
generatedAt ? ` * Generated: ${generatedAt}` : null,
" *",
" * This is a snapshot. The table still revalidates it at runtime, so an",
" * endpoint whose schema has moved on will correct itself — but regenerate",
" * this file when that happens, or every first paint starts from stale",
" * columns.",
].filter((line): line is string => line !== null);
return [
"/**",
...provenance,
" */",
"",
// Built with `JSON.stringify` rather than an interpolated string literal:
// it escapes the specifier properly, and it keeps this line from reading as
// a real import to any tool that scans this file's own imports.
`import type { TableManifest } from ${JSON.stringify(typeImport)};`,
"",
`export const ${exportName}: TableManifest = ${JSON.stringify(parsed, null, 2)};`,
"",
].join("\n");
}
/**
* Fetch a manifest and render it as a module, ready to write to disk.
*
* The whole `dtf pull` step, minus the file write — which is left to the
* caller so this stays runtime-agnostic and needs no filesystem access.
*
* @example
* ```ts
* // scripts/pull-manifest.ts
* import { writeFileSync } from "node:fs";
* import { pullManifestModule } from "@/lib/table-schema";
*
* const url = "https://api.example.com/logs/schema";
* writeFileSync("src/app/logs/manifest.ts", await pullManifestModule(url));
* ```
*/
export async function pullManifestModule(
url: string,
options?: SnapshotOptions & {
headers?: HeadersInit;
fetch?: typeof fetch;
},
): Promise<string> {
const doFetch = options?.fetch ?? fetch;
const response = await doFetch(url, { headers: options?.headers });
if (!response.ok) {
throw new Error(
`Could not pull the manifest from ${url}: ${response.status} ${response.statusText}`.trim(),
);
}
return manifestToModule(await response.json(), {
source: url,
generatedAt: new Date(),
...options,
});
}
src/lib/table-schema/infer.ts
import { compact, defaultDisplayForKind } from "./col";
import { SCHEMA_JSON_VERSION } from "./serialize";
import type {
ColKind,
ColumnDescriptor,
DisplayDescriptor,
FilterDescriptor,
SchemaJSON,
} from "./types";
/** A descriptor plus the key it will be registered under. */
type InferredColumn = ColumnDescriptor & { key: string };
// Unix ms timestamps are 13-digit numbers (> Sep 2001, < Nov 2286)
const UNIX_MS_MIN = 1_000_000_000_000;
const UNIX_MS_MAX = 9_999_999_999_999;
function isIso8601(value: string): boolean {
return (
/^\d{4}-\d{2}-\d{2}(T[\d:.Z+\-]+)?$/.test(value) &&
!isNaN(Date.parse(value))
);
}
function isUnixMs(value: number): boolean {
return (
Number.isInteger(value) && value >= UNIX_MS_MIN && value <= UNIX_MS_MAX
);
}
/** Convert camelCase, snake_case, or kebab-case key to a human-readable label. */
function keyToLabel(key: string): string {
let label = key.replace(/[-_]/g, " ");
label = label.replace(/([a-z])([A-Z])/g, "$1 $2");
return label.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* The fields every inferred column shares. `display` comes from the one
* `defaultDisplayForKind` in `col.ts` — this module used to carry its own copy.
*/
function inferredCommon(
label: string,
kind: ColKind,
filter: FilterDescriptor | null,
rule: string,
) {
return {
label,
optional: false,
display: defaultDisplayForKind(kind) as DisplayDescriptor,
hidden: false,
enableHiding: true,
hideHeader: false,
resizable: false,
sortable: false,
filter,
sheet: {} as { label?: string },
provenance: { source: "inferred" as const, rule },
};
}
function makeDescriptor(
key: string,
label: string,
kind: Exclude<ColKind, "array" | "enum">,
filter: FilterDescriptor | null,
rule: string,
): InferredColumn {
return { key, ...inferredCommon(label, kind, filter, rule), kind };
}
/** Split a key into lowercase words, handling camelCase, snake_case, and kebab-case. */
function keyToWords(key: string): string[] {
return key
.replace(/([a-z])([A-Z])/g, "$1_$2")
.split(/[-_]/)
.map((w) => w.toLowerCase())
.filter(Boolean);
}
const ID_WORDS = new Set(["id", "uuid", "hash", "token", "key"]);
const CODE_WORDS = new Set([
"path",
"url",
"uri",
"endpoint",
"route",
"host",
"link",
"href",
"website",
]);
const LATENCY_WORDS = new Set([
"latency",
"duration",
"elapsed",
"delay",
"wait",
"ttfb",
"rtt",
"ping",
]);
const SIZE_WORDS = new Set(["size", "bytes", "length"]);
const LEVEL_WORDS = new Set(["level", "severity"]);
const TRACE_ID_WORDS = new Set(["trace", "span", "request"]);
const FAVORITE_WORDS = new Set(["favorite", "starred", "bookmarked", "pinned"]);
const EMAIL_WORDS = new Set(["email", "mail"]);
const STATUS_WORDS = new Set(["status", "state"]);
const HEALTH_WORDS = new Set([
"health",
"score",
"rating",
"accuracy",
"uptime",
]);
const PROGRESS_WORDS = new Set(["progress", "completion", "percentage"]);
const HP_WORDS = new Set(["hp", "hitpoints"]);
/** Semantic color mapping for status-like enum values. */
const STATUS_COLORS: Record<string, string> = {
active: "#22c55e",
completed: "#22c55e",
success: "#22c55e",
published: "#22c55e",
approved: "#22c55e",
pending: "#f59e0b",
draft: "#f59e0b",
inactive: "#f59e0b",
paused: "#f59e0b",
error: "#ef4444",
failed: "#ef4444",
rejected: "#ef4444",
cancelled: "#ef4444",
archived: "#6b7280",
deleted: "#6b7280",
disabled: "#6b7280",
};
/** Neutral palette for enum values without semantic meaning. */
const NEUTRAL_PALETTE = [
"#6366f1",
"#8b5cf6",
"#ec4899",
"#14b8a6",
"#f97316",
"#06b6d4",
"#84cc16",
"#eab308",
"#ef4444",
"#64748b",
];
/** Generate a colorMap for enum values, using semantic colors where possible. */
function generateColorMap(values: readonly string[]): Record<string, string> {
const colorMap: Record<string, string> = {};
let neutralIdx = 0;
for (const value of values) {
const lower = value.toLowerCase();
if (STATUS_COLORS[lower]) {
colorMap[value] = STATUS_COLORS[lower];
} else {
colorMap[value] = NEUTRAL_PALETTE[neutralIdx % NEUTRAL_PALETTE.length]!;
neutralIdx++;
}
}
return colorMap;
}
/** Post-process an inferred descriptor with smart display/config heuristics. */
function enhanceDescriptor(descriptor: InferredColumn): InferredColumn {
const words = keyToWords(descriptor.key);
const joined = words.join("");
const d = { ...descriptor };
const hasIdWord = words.some((w) => ID_WORDS.has(w));
const hasCodeWord = words.some((w) => CODE_WORDS.has(w));
const hasLatencyWord =
words.some((w) => LATENCY_WORDS.has(w)) || joined.includes("responsetime");
const hasSizeWord = words.some((w) => SIZE_WORDS.has(w));
const hasLevelWord = words.some((w) => LEVEL_WORDS.has(w));
const isTraceId = hasIdWord && words.some((w) => TRACE_ID_WORDS.has(w));
const hasFavoriteWord = words.some((w) => FAVORITE_WORDS.has(w));
const hasEmailWord = words.some((w) => EMAIL_WORDS.has(w));
const hasStatusWord = words.some((w) => STATUS_WORDS.has(w));
const hasHealthWord = words.some((w) => HEALTH_WORDS.has(w));
const hasProgressWord = words.some((w) => PROGRESS_WORDS.has(w));
const hasHpWord = words.some((w) => HP_WORDS.has(w));
// ID-like columns → code display, not sortable
if (hasIdWord) {
d.display = { type: "code" };
d.sortable = false;
// Trace/span/request IDs → hidden, not filterable (matches col.presets.traceId())
if (isTraceId) {
d.hidden = true;
d.filter = null;
}
}
// Favorite/starred booleans → star display, hide column header
else if (hasFavoriteWord && d.kind === "boolean") {
d.display = { type: "star" };
d.hideHeader = true;
}
// Email columns → code display
else if (hasEmailWord && d.kind === "string") {
d.display = { type: "code" };
}
// Path/URL-like columns → code display
else if (hasCodeWord) {
d.display = { type: "code" };
}
// Latency-like number columns → heatmap with ms unit, sortable
else if (hasLatencyWord && d.kind === "number") {
// `compact` for the same reason the builder uses it: a zero-variance
// sample gives the column an `input` filter with no bounds, and a display
// carrying `min: undefined` is not the canonical form of one that omits it.
d.display = compact({
type: "heatmap",
unit: "ms",
min: d.filter?.min,
max: d.filter?.max,
}) as DisplayDescriptor;
d.sortable = true;
}
// Size-like number columns → number with B unit, sortable
else if (hasSizeWord && d.kind === "number") {
d.display = { type: "number", unit: "B" };
d.sortable = true;
}
// Health/score/rating → gauge display (min always 0 for visual baseline)
else if (hasHealthWord && d.kind === "number") {
d.display = compact({
type: "gauge",
min: 0,
max: d.filter?.max,
}) as DisplayDescriptor;
d.sortable = true;
}
// HP/hitpoints → bar display (min always 0 for visual baseline)
else if (hasHpWord && d.kind === "number") {
d.display = compact({
type: "bar",
min: 0,
max: d.filter?.max,
}) as DisplayDescriptor;
d.sortable = true;
}
// Progress/completion → bar display (min always 0 for visual baseline)
else if (hasProgressWord && d.kind === "number") {
d.display = compact({
type: "bar",
min: 0,
max: d.filter?.max,
}) as DisplayDescriptor;
d.sortable = true;
}
// Sortable defaults by type (unless ID-like)
if (!hasIdWord) {
if (d.kind === "timestamp" || d.kind === "number") {
d.sortable = true;
}
}
// Log level / severity enums: expand filter by default (matches col.presets.logLevel())
if (hasLevelWord && d.kind === "enum" && d.filter) {
d.filter = { ...d.filter, defaultOpen: true };
}
// Status/state enums → semantic colorMap on badge display
if (hasStatusWord && d.kind === "enum") {
d.display = { type: "badge", colorMap: generateColorMap(d.enumValues) };
}
// Column sizing defaults
const sizeDefaults: Record<string, number> = {
boolean: 100,
timestamp: 220,
number: 120,
enum: 130,
};
if (sizeDefaults[d.kind] !== undefined) {
d.size = sizeDefaults[d.kind];
}
return d;
}
/** Infer the item descriptor of an array column from its flattened items. */
function inferArrayItem(items: unknown[]): ColumnDescriptor {
const common = (kind: ColKind, rule: string) =>
inferredCommon("", kind, null, rule);
if (items.length === 0) {
return { ...common("string", "array-item:empty"), kind: "string" };
}
if (items.every((v) => typeof v === "string")) {
const distinct = new Set(items as string[]);
if (distinct.size <= 10) {
return {
...common("enum", "array-item:enum"),
kind: "enum",
enumValues: Array.from(distinct),
};
}
return { ...common("string", "array-item:string"), kind: "string" };
}
if (items.every((v) => typeof v === "number")) {
return { ...common("number", "array-item:number"), kind: "number" };
}
if (items.every((v) => typeof v === "boolean")) {
return { ...common("boolean", "array-item:boolean"), kind: "boolean" };
}
return { ...common("string", "array-item:mixed"), kind: "string" };
}
function inferColDescriptor(key: string, values: unknown[]): InferredColumn {
const label = keyToLabel(key);
const nonNull = values.filter((v) => v !== null && v !== undefined);
if (nonNull.length === 0) {
return makeDescriptor(
key,
label,
"string",
{ type: "input", defaultOpen: false, commandDisabled: false },
"empty-column",
);
}
// Timestamp: ISO 8601 strings
if (nonNull.every((v) => typeof v === "string" && isIso8601(v as string))) {
return makeDescriptor(
key,
label,
"timestamp",
{ type: "timerange", defaultOpen: false, commandDisabled: true },
"iso8601-string",
);
}
// Timestamp: Unix ms numbers
if (nonNull.every((v) => typeof v === "number" && isUnixMs(v as number))) {
return makeDescriptor(
key,
label,
"timestamp",
{ type: "timerange", defaultOpen: false, commandDisabled: true },
"unix-ms-number",
);
}
// Boolean — options match `col.boolean()` exactly. Inference used to omit
// them and let `generateFilterFields` derive "Yes"/"No", while the factory
// baked in "true"/"false": two construction paths producing different filter
// options for the same column kind. The codegen round trip cannot hold while
// they disagree, because no chain step can clear the factory's options.
if (nonNull.every((v) => v === true || v === false)) {
return makeDescriptor(
key,
label,
"boolean",
{
type: "checkbox",
defaultOpen: false,
commandDisabled: false,
options: [
{ label: "true", value: true },
{ label: "false", value: false },
],
},
"boolean",
);
}
// Number
if (nonNull.every((v) => typeof v === "number")) {
const nums = nonNull as number[];
const min = Math.min(...nums);
const max = Math.max(...nums);
const filter: FilterDescriptor =
min !== max
? {
type: "slider",
defaultOpen: false,
commandDisabled: false,
min,
max,
}
: { type: "input", defaultOpen: false, commandDisabled: false };
return makeDescriptor(key, label, "number", filter, "number");
}
// Array — the item type is always described, so a `string[]` column with more
// distinct values than the enum threshold stays a `string[]` column.
if (nonNull.every((v) => Array.isArray(v))) {
const allItems = (nonNull as unknown[][])
.flat()
.filter((v) => v !== null && v !== undefined);
const arrayItem = inferArrayItem(allItems);
const filter: FilterDescriptor | null =
arrayItem.kind === "enum"
? {
type: "checkbox",
defaultOpen: false,
commandDisabled: false,
options: arrayItem.enumValues.map((v) => ({ label: v, value: v })),
}
: null;
return {
key,
...inferredCommon(label, "array", filter, "array"),
kind: "array",
arrayItem,
};
}
// Record (plain object, non-array)
if (nonNull.every((v) => typeof v === "object" && !Array.isArray(v))) {
return makeDescriptor(key, label, "record", null, "record");
}
// String: check if enum (≤ 10 distinct values)
if (nonNull.every((v) => typeof v === "string")) {
const distinct = new Set(nonNull as string[]);
if (distinct.size <= 10) {
const enumValues = Array.from(distinct);
return {
key,
...inferredCommon(
label,
"enum",
{
type: "checkbox",
defaultOpen: false,
commandDisabled: false,
options: enumValues.map((v) => ({ label: v, value: v })),
},
"string-enum",
),
kind: "enum",
enumValues,
};
}
return makeDescriptor(
key,
label,
"string",
{ type: "input", defaultOpen: false, commandDisabled: false },
"string",
);
}
// Fallback: mixed or unrecognised types — warn and treat as string
const types = [
...new Set(nonNull.map((v) => (Array.isArray(v) ? "array" : typeof v))),
];
console.warn(
`[inferSchemaFromJSON] Column "${key}" has mixed or ambiguous types (${types.join(", ")}). ` +
`Falling back to string input filter.`,
);
return makeDescriptor(
key,
label,
"string",
{ type: "input", defaultOpen: false, commandDisabled: false },
"mixed-fallback",
);
}
/**
* Infer a SchemaJSON from an array of plain data objects.
*
* Walks all rows, collects per-key values, and infers the best ColKind and
* FilterType for each column using these heuristics:
* - `timestamp`: ISO 8601 strings or Unix-ms numbers
* - `boolean`: all values strictly true/false
* - `number`: all non-null values are typeof "number"
* - `enum`: strings with ≤ 10 distinct values across the sample
* - `array`: values are arrays (item type inferred recursively)
* - `record`: values are plain objects (non-array)
* - `string`: fallback
*
* Number columns with min ≠ max get a "slider" filter; otherwise "input".
*/
export function inferSchemaFromJSON(data: unknown[]): SchemaJSON {
if (!Array.isArray(data) || data.length === 0) {
return { version: SCHEMA_JSON_VERSION, columns: [] };
}
// Collect all keys and their values across rows (preserving insertion order)
const keyValues = new Map<string, unknown[]>();
for (const row of data) {
if (typeof row !== "object" || row === null || Array.isArray(row)) continue;
for (const [key, value] of Object.entries(row as Record<string, unknown>)) {
if (!keyValues.has(key)) keyValues.set(key, []);
keyValues.get(key)!.push(value);
}
}
const columns = Array.from(keyValues.entries()).map(([key, values]) =>
enhanceDescriptor(inferColDescriptor(key, values)),
);
return { version: SCHEMA_JSON_VERSION, columns };
}
src/lib/table-schema/to-typescript.ts
import { col } from "./col";
import { presets } from "./presets";
import type {
ColBuilder,
ColumnDescriptor,
ColumnDescriptorCommon,
DatePresetDescriptor,
JsonValue,
SchemaJSON,
} from "./types";
/**
* Emit the `col.*` / `col.presets.*` factory call a descriptor came from, and
* produce the descriptor that call yields on its own.
*
* Provenance is recorded at construction, so this reads it instead of
* pattern-matching the descriptor's shape back into a guess at the preset.
*/
function factoryFor(c: ColumnDescriptor): {
source: string;
baseline: ColumnDescriptor;
} {
if (c.provenance.source === "preset") {
const factory = presets[c.provenance.preset as keyof typeof presets];
if (typeof factory === "function") {
const args = c.provenance.args;
// Re-running the preset with its recorded arguments gives the exact
// baseline the author started from, so the chain below emits only what
// they actually changed.
const builder = (
factory as (...a: unknown[]) => ColBuilder<unknown, any>
)(...args.map((a) => (a === null ? undefined : a)));
return {
source: `col.presets.${c.provenance.preset}(${args.map(emitValue).join(", ")})`,
baseline: builder._descriptor,
};
}
// Unknown preset name (schema written by a newer build) — fall through to
// the primitive factory rather than emitting a call that will not compile.
}
if (c.kind === "enum") {
return {
source: `col.enum([${c.enumValues.map(emitValue).join(", ")}])`,
baseline: col.enum(c.enumValues)._descriptor,
};
}
if (c.kind === "array") {
const item = factoryFor(c.arrayItem);
const itemChain = chainFor(c.arrayItem, item.baseline);
// `col.array()` requires an item builder — emitting a bare `col.array()`
// is what used to produce code that did not compile.
const itemSource = item.source + itemChain.join("");
return {
source: `col.array(${itemSource})`,
baseline: col.array(rebuild(c.arrayItem))._descriptor,
};
}
const factory = col[c.kind] as () => ColBuilder<unknown, any>;
return { source: `col.${c.kind}()`, baseline: factory()._descriptor };
}
/** Rebuild a builder from a descriptor — used to seed `col.array`'s baseline. */
function rebuild(c: ColumnDescriptor): ColBuilder<unknown, any> {
if (c.kind === "enum") return col.enum(c.enumValues);
if (c.kind === "array") return col.array(rebuild(c.arrayItem));
return (col[c.kind] as () => ColBuilder<unknown, any>)();
}
function emitValue(value: JsonValue | readonly string[] | undefined): string {
if (value === undefined || value === null) return "undefined";
// Arrays are spaced by hand so preset args (`logLevel(["a", "b"])`) match the
// per-value assembly used elsewhere (`col.enum(["a", "b"])`) — bare
// `JSON.stringify` would emit one of them without spaces.
if (Array.isArray(value)) {
return `[${value.map((v) => emitValue(v as JsonValue)).join(", ")}]`;
}
return JSON.stringify(value);
}
/**
* Emit a timerange preset.
*
* The descriptor stores `from`/`to` as ISO strings so they survive JSON, but
* `.filterable("timerange", { presets })` takes `DatePreset`s holding real
* `Date`s — printing the descriptor verbatim produced code that threw
* `preset.from.toISOString is not a function` on the first run.
*/
function emitDatePreset(preset: DatePresetDescriptor): string {
return (
`{ label: ${JSON.stringify(preset.label)}, ` +
`shortcut: ${JSON.stringify(preset.shortcut)}, ` +
`from: new Date(${JSON.stringify(preset.from)}), ` +
`to: new Date(${JSON.stringify(preset.to)}) }`
);
}
/** Emit an object literal with unquoted keys, matching the docs' house style. */
function emitObject(entries: Record<string, unknown>): string {
const parts = Object.entries(entries)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`);
return `{ ${parts.join(", ")} }`;
}
function sameJSON(a: unknown, b: unknown): boolean {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
/**
* One emitter per serializable descriptor field.
*
* The `-?` plus full key coverage means adding a field to
* `ColumnDescriptorCommon` without adding an emitter here is a **compile
* error**, not a silently dropped chain step.
*/
type Emitter<K extends keyof ColumnDescriptorCommon> = (
value: ColumnDescriptorCommon[K],
baseline: ColumnDescriptor,
descriptor: ColumnDescriptor,
) => string[];
type EmitterMap = { [K in keyof ColumnDescriptorCommon]-?: Emitter<K> };
const EMITTERS: EmitterMap = {
label: (value) => [`.label(${JSON.stringify(value)})`],
description: (value) =>
value === undefined ? [] : [`.description(${JSON.stringify(value)})`],
display: (value) => {
const { type, ...options } = value as { type: string } & Record<
string,
unknown
>;
const hasOptions = Object.values(options).some((v) => v !== undefined);
return [
hasOptions
? `.display(${JSON.stringify(type)}, ${emitObject(options)})`
: `.display(${JSON.stringify(type)})`,
];
},
filter: (value) => {
if (value === null) return [".notFilterable()"];
const parts: string[] = [];
const { type, defaultOpen, commandDisabled, options, presets, ...rest } =
value;
if (type === "checkbox") {
parts.push(
options
? `.filterable("checkbox", { options: [${options
.map((o) => emitObject({ label: o.label, value: o.value }))
.join(", ")}] })`
: `.filterable("checkbox")`,
);
} else if (type === "slider") {
parts.push(`.filterable("slider", ${emitObject(rest)})`);
} else if (type === "timerange") {
parts.push(
presets
? `.filterable("timerange", { presets: [${presets
.map(emitDatePreset)
.join(", ")}] })`
: `.filterable("timerange")`,
);
} else {
parts.push(`.filterable("input")`);
}
if (defaultOpen) parts.push(".defaultOpen()");
if (commandDisabled) parts.push(".commandDisabled()");
return parts;
},
sheet: (value) => {
if (value === null) return [];
const args = emitObject(value as Record<string, unknown>);
return [args === "{ }" ? ".sheet()" : `.sheet(${args})`];
},
size: (value) => (value === undefined ? [] : [`.size(${value})`]),
minSize: (value) => (value === undefined ? [] : [`.minSize(${value})`]),
hidden: (value) => (value ? [".hidden()"] : []),
hideHeader: (value) => (value ? [".hideHeader()"] : []),
resizable: (value) => (value ? [".resizable()"] : []),
// Only reached when the value differs from the baseline, so `false` here
// means a preset (`timestamp`, `latency`, …) switched sorting on and the
// author switched it off again.
sortable: (value) => [value ? ".sortable()" : ".sortable(false)"],
optional: (value) => (value ? [".optional()"] : []),
// `enableHiding: false` together with `hidden` is exactly `.sheetOnly()`;
// on its own it is only reachable via `col.select()`, whose baseline already
// has it, so nothing needs emitting.
enableHiding: (value, _baseline, descriptor) =>
value === false && descriptor.hidden ? [".sheetOnly()"] : [],
// Not a chain step — provenance is how the factory call was chosen.
provenance: () => [],
};
/**
* The order chain steps are emitted in. Listed explicitly because `.sheetOnly()`
* clears the filter and must come after `.filterable()` would have set it.
*/
export const EMIT_ORDER: (keyof ColumnDescriptorCommon)[] = [
"label",
"description",
"display",
"filter",
"enableHiding",
"sortable",
"hidden",
"hideHeader",
"resizable",
"optional",
"size",
"minSize",
"sheet",
];
/** Emit only the steps where the descriptor differs from the factory baseline. */
function chainFor(c: ColumnDescriptor, baseline: ColumnDescriptor): string[] {
const parts: string[] = [];
for (const key of EMIT_ORDER) {
const value = c[key];
if (sameJSON(value, baseline[key])) continue;
// `.sheetOnly()` already implies `.hidden()` and `.notFilterable()`.
//
// The `filter === null` check is the whole reason this branch is safe:
// without it, any descriptor shaped like a sheet-only column had its
// filter dropped from the emitted source, silently. `validateSchema`
// rejects that combination, so it can only arrive here through a raw
// `SchemaJSON` that never went through a schema — and then the filter is
// emitted rather than swallowed.
if (
c.enableHiding === false &&
c.hidden &&
c.filter === null &&
baseline.enableHiding !== false &&
(key === "hidden" || key === "filter")
) {
continue;
}
const emit = EMITTERS[key] as Emitter<typeof key>;
parts.push(...emit(value as never, baseline, c));
}
return parts;
}
/**
* Convert a `SchemaJSON` descriptor to a `createTableSchema(...)` TypeScript
* source code string.
*
* The output is ready to copy-paste into a project that imports from
* `@/lib/table-schema`. Custom cell renderers are not serialized, so the
* emitted chain uses the descriptor's fallback display for those columns.
*
* @example
* ```ts
* const ts = schemaToTypeScript(tableSchema.toJSON());
* // → 'import { createTableSchema, col } from "@/lib/table-schema"; ...'
* ```
*/
export function schemaToTypeScript(json: SchemaJSON): string {
const lines: string[] = [
'import { createTableSchema, col } from "@/lib/table-schema";',
"",
"export const schema = createTableSchema({",
];
for (const { key, ...descriptor } of json.columns) {
const c = descriptor as ColumnDescriptor;
const { source, baseline } = factoryFor(c);
const chain = [source, ...chainFor(c, baseline)].join("\n ");
lines.push(` ${key}: ${chain},`);
}
lines.push("});");
return lines.join("\n");
}
src/lib/filters/types.ts
import type {
ColBuilder,
ColKind,
FilterType,
TableSchemaDefinition,
} from "../table-schema/types";
export type { ColKind, FilterType };
/** The member type of a checkbox filter: the item type for an array column. */
type FilterItem<T> =
NonNullable<T> extends readonly (infer U)[] ? U : NonNullable<T>;
/**
* The value one column's filter accepts, from its `ColBuilder<T, F>`.
*
* Mirrors `normalize`: dispatch is on the declared filter type, and the
* member type comes from the column's data type — so a checkbox on
* `col.enum(LEVELS)` only accepts members of `LEVELS`. Distributes over `F`,
* so a column whose filter type was never narrowed (`col.number()` allows
* three) accepts any of them.
*
* A checkbox on a non-scalar member type is `never`: `col.array()` takes any
* item builder, so `col.array(col.record())` type-checks, but `normalize` has
* nothing to compare a record against. Advertising the member type there
* would promise a filter the engine cannot plan.
*/
export type FilterValueFor<T, F extends FilterType> = F extends "input"
? NonNullable<T> extends number
? number
: string
: F extends "checkbox"
? [FilterItem<T>] extends [Scalar]
? FilterItem<T> | readonly FilterItem<T>[]
: never
: F extends "slider"
? number | readonly [number, number]
: F extends "timerange"
? Date | readonly [Date, Date]
: never;
/**
* The filter values a table schema accepts, keyed by filterable column.
*
* Columns that are `.notFilterable()` (or `col.record()` / `col.select()`,
* which never are) are absent, so a key typo or a guard on an unfilterable
* column is a compile error — the same rule `defineActions` enforces at
* runtime for a `when` clause.
*/
export type FilterValues<TSchema extends TableSchemaDefinition> = {
[K in keyof TSchema as TSchema[K] extends ColBuilder<unknown, infer F>
? [F] extends [never]
? never
: K
: never]: TSchema[K] extends ColBuilder<infer T, infer F>
? FilterValueFor<T, F>
: never;
};
/** A value a filter can compare against. */
export type Scalar = string | number | boolean;
/**
* A column's declared filter semantics, flattened to plain data.
*
* This is the whole input to the normalization table. Backends never see
* anything else about a column, which is what stops them re-deriving semantics
* from the runtime shape of a value.
*/
export type FilterSpec = {
/** Dot-notation — the ONE identity space, matching the table schema key. */
key: string;
type: FilterType;
kind: ColKind;
/** Present when `kind === "array"`. */
itemKind?: ColKind;
options?: readonly Scalar[];
min?: number;
max?: number;
};
/**
* The canonical, backend-neutral operation set.
*
* CLOSED union — exactly six members. Every backend implements all six with an
* exhaustive switch and no default branch, so adding a seventh is a compile
* error at every backend rather than a silent fallthrough. That is the point of
* the design, not a defect: a new op is a breaking change to every engine.
*/
export type FilterOp =
/** Case-insensitive substring match. */
| { op: "substring"; key: string; value: string }
| { op: "equals"; key: string; value: Scalar }
/** Scalar column, value ∈ set. */
| { op: "oneOf"; key: string; values: Scalar[] }
/** Array column, column ∩ set ≠ ∅. */
| { op: "overlaps"; key: string; values: Scalar[] }
/** Inclusive on both ends. */
| { op: "numberRange"; key: string; min: number; max: number }
/** Inclusive on both ends. */
| { op: "dateRange"; key: string; from: Date; to: Date };
/** Restrict which keys `plan` / `matches` consider. */
export type FilterSelection = {
only?: readonly string[];
exclude?: readonly string[];
};
src/lib/filters/normalize.ts
import type { ColKind, FilterOp, FilterSpec, Scalar } from "./types";
/** Unix ms timestamps are 13-digit numbers (> Sep 2001, < Nov 2286). */
const UNIX_MS_MIN = 1_000_000_000_000;
/**
* Is this filter value active?
*
* Inactive means "the user has not filtered on this column": absent, cleared,
* an empty multi-select, or a value that cannot be compared at all. This was
* written four slightly different ways across four engines.
*/
export function isActive(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.length > 0;
if (typeof value === "number") return !Number.isNaN(value);
if (value instanceof Date) return !Number.isNaN(value.getTime());
if (Array.isArray(value)) {
// ANY usable member makes the filter active. `normalize` drops the members
// it cannot use, so requiring all of them to be usable would make one blank
// entry silently delete the whole filter — and inconsistently, since
// `["abc", 500]` on a slider degenerates to 500 while `["", 500]` vanished.
return value.some(isActive);
}
return true;
}
/** Coerce to a `Date`, or `null` if it cannot be one. */
function asDate(value: unknown): Date | null {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
// superjson revives Dates, but MCP args and LLM output arrive as ISO strings
// or epoch millis.
if (typeof value === "string" || typeof value === "number") {
// Epoch millis that arrived as a string: `new Date("1700000000000")` is an
// Invalid Date while `new Date(1700000000000)` is not. The command palette
// serializes timeranges with `getTime()`, and JSON has no Date type, so
// this shape is common. The magnitude test disambiguates it from a bare
// year like "2024", which `Date` should keep parsing as a year.
if (typeof value === "string" && /^\d+$/.test(value)) {
const millis = Number(value);
if (millis >= UNIX_MS_MIN) {
const date = new Date(millis);
return Number.isNaN(date.getTime()) ? null : date;
}
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
return null;
}
function asNumber(value: unknown): number | null {
if (typeof value === "number") return Number.isNaN(value) ? null : value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}
function asScalar(value: unknown): Scalar | null {
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
return null;
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [value];
}
function asBoolean(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return null;
}
/**
* Coerce one checkbox member to the column's declared type.
*
* The URL layer, MCP args, and LLM output all deliver `["200", "500"]` for a
* numeric column. Without this, the SQL backend received
* `inArray(integerColumn, ["200", "500"])` and depended on Postgres casting the
* literals, and `coerce()` — the entry point documented as producing validated
* values — handed back strings for a number column.
*/
function asDeclaredScalar(kind: ColKind, value: unknown): Scalar | null {
switch (kind) {
case "number":
return asNumber(value);
case "boolean":
return asBoolean(value);
default: {
const scalar = asScalar(value);
return scalar === null ? null : String(scalar);
}
}
}
function startOfDay(date: Date): Date {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
}
function endOfDay(date: Date): Date {
const result = new Date(date);
result.setHours(23, 59, 59, 999);
return result;
}
/**
* Turn one declared filter and its value into a canonical op.
*
* This is the whole design. Dispatch is on the **declared** `(type, kind)`
* pair — never on the runtime shape of `value`. A numeric checkbox is
* `{ type: "checkbox", kind: "number" }`, so `[200, 500]` can only become
* `oneOf`; `numberRange` is unreachable from a checkbox and
* `BETWEEN 200 AND 500` is therefore unrepresentable. Backends never see array
* length, so length-based dispatch has nowhere to live.
*
* Returns `null` when the filter is inactive or the value cannot be coerced
* into the declared shape.
*/
export function normalize(spec: FilterSpec, value: unknown): FilterOp | null {
if (!isActive(value)) return null;
const { key } = spec;
switch (spec.type) {
case "input": {
// A number column's text box is an exact match, not a substring search —
// substring matching on a stringified number makes "5" match 1500.
if (spec.kind === "number") {
const parsed = asNumber(Array.isArray(value) ? value[0] : value);
return parsed === null ? null : { op: "equals", key, value: parsed };
}
const raw = Array.isArray(value) ? value[0] : value;
const text = asScalar(raw);
return text === null
? null
: { op: "substring", key, value: String(text) };
}
case "checkbox": {
// For an array column the members are compared against the *item* type.
const memberKind =
spec.kind === "array" ? (spec.itemKind ?? "string") : spec.kind;
const values = asArray(value)
.map((member) => asDeclaredScalar(memberKind, member))
.filter((v): v is Scalar => v !== null);
if (values.length === 0) return null;
// An array column is a set on both sides — overlap, not membership.
return spec.kind === "array"
? { op: "overlaps", key, values }
: { op: "oneOf", key, values };
}
case "slider": {
const numbers = asArray(value)
.map(asNumber)
.filter((v): v is number => v !== null);
if (numbers.length === 0) return null;
// A single-handle slider is a degenerate range, not an equality — the
// two disagree once the column holds non-integers.
const [first, second] = numbers;
const min = first!;
const max = second ?? first!;
return {
op: "numberRange",
key,
min: Math.min(min, max),
max: Math.max(min, max),
};
}
case "timerange": {
const dates = asArray(value)
.map(asDate)
.filter((v): v is Date => v !== null);
if (dates.length === 0) return null;
// One date means "that whole day".
if (dates.length === 1) {
return {
op: "dateRange",
key,
from: startOfDay(dates[0]!),
to: endOfDay(dates[0]!),
};
}
const [from, to] = dates;
return from!.getTime() <= to!.getTime()
? { op: "dateRange", key, from: from!, to: to! }
: { op: "dateRange", key, from: to!, to: from! };
}
}
}
src/lib/filters/evaluate.ts
import type { FilterOp, Scalar } from "./types";
/**
* Read a possibly dotted key off a row.
*
* Table schema keys are dot-notation (`"timing.dns"`). Rows may store that
* either as a literal flat key or as a nested object, so both are tried —
* a flat hit wins, since that is the wire shape.
*/
export function getValueAtKey(row: unknown, key: string): unknown {
if (row === null || typeof row !== "object") return undefined;
const flat = (row as Record<string, unknown>)[key];
if (flat !== undefined) return flat;
if (!key.includes(".")) return undefined;
let current: unknown = row;
for (const part of key.split(".")) {
if (current === null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
function sameScalar(cell: unknown, value: Scalar): boolean {
if (cell === value) return true;
// The URL layer can only produce strings, so a number column filtered from a
// link arrives as one. Compare by string as a last resort rather than by
// coercing, which would make `0 == false` true.
if (
(typeof cell === "number" || typeof cell === "boolean") &&
typeof value === "string"
) {
return String(cell) === value;
}
if (typeof cell === "string" && typeof value !== "string") {
return cell === String(value);
}
return false;
}
function asComparableNumber(cell: unknown): number | null {
if (typeof cell === "number") return Number.isNaN(cell) ? null : cell;
if (typeof cell === "string" && cell.trim() !== "") {
const parsed = Number(cell);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}
function asComparableTime(cell: unknown): number | null {
if (cell instanceof Date) {
return Number.isNaN(cell.getTime()) ? null : cell.getTime();
}
if (typeof cell === "string" || typeof cell === "number") {
const time = new Date(cell).getTime();
return Number.isNaN(time) ? null : time;
}
return null;
}
/**
* Evaluate one canonical op against one cell value.
*
* Exhaustive over `FilterOp` with no default branch — a seventh op fails to
* compile here rather than silently passing every row.
*/
export function evaluateOp(op: FilterOp, cell: unknown): boolean {
switch (op.op) {
case "substring": {
if (cell === null || cell === undefined) return false;
// Case-insensitive, matching the SQL side's `ilike`.
return String(cell).toLowerCase().includes(op.value.toLowerCase());
}
case "equals":
return sameScalar(cell, op.value);
case "oneOf":
return op.values.some((value) => sameScalar(cell, value));
case "overlaps": {
// The column is a set. A scalar cell is treated as a one-element set so
// a single-value column still matches — this is where the infinite route
// used to look only at `row[key][0]`.
const cells = Array.isArray(cell) ? cell : [cell];
return cells.some((item) =>
op.values.some((value) => sameScalar(item, value)),
);
}
case "numberRange": {
const value = asComparableNumber(cell);
return value !== null && value >= op.min && value <= op.max;
}
case "dateRange": {
const time = asComparableTime(cell);
return (
time !== null && time >= op.from.getTime() && time <= op.to.getTime()
);
}
}
}
src/lib/filters/index.ts
import { resolveColumns } from "../table-schema/col";
import type {
ColumnDescriptor,
SchemaJSON,
TableSchemaDefinition,
} from "../table-schema/types";
import { evaluateOp, getValueAtKey } from "./evaluate";
import { isActive, normalize } from "./normalize";
import type {
FilterOp,
FilterSelection,
FilterSpec,
FilterValues,
Scalar,
} from "./types";
export { evaluateOp, getValueAtKey } from "./evaluate";
export { isActive, normalize } from "./normalize";
export type {
ColKind,
FilterOp,
FilterSelection,
FilterSpec,
FilterType,
FilterValueFor,
FilterValues,
Scalar,
} from "./types";
/**
* Where a `Filters` gets its declarations.
*
* `SchemaJSON` is here so the declaration can cross the `"use client"` boundary
* as data: `table-schema.tsx` cannot be imported server-side, but its
* `toJSON()` can.
*/
export type FilterSource =
| TableSchemaDefinition
| SchemaJSON
| readonly FilterSpec[];
/**
* `TValues` is the shape of the values these semantics accept, keyed by
* filterable column. It is `FilterValues<typeof definition>` when built from
* a table schema definition and the loose `Record<string, unknown>` from
* `SchemaJSON` or specs, where nothing about the columns is known at compile
* time. Search params stay untyped either way — `plan` / `matches` take what
* the wire delivers — but declarations written by hand (an action's `when`
* guard) and the output of `coerce` are checked against it.
*/
export interface Filters<TValues = Record<string, unknown>> {
readonly specs: readonly FilterSpec[];
/** Look up one column's declaration. */
spec(key: string): FilterSpec | undefined;
/**
* Values → canonical ops. Never guesses from the shape of a value.
* Inactive and unknown keys are dropped.
*/
plan(
values: Record<string, unknown>,
selection?: FilterSelection,
): FilterOp[];
/** In-memory predicate. Replaces `filterData` and `filterGenericData`. */
matches(
values: Record<string, unknown>,
row: unknown,
selection?: FilterSelection,
): boolean;
/** Filter a whole collection. */
apply<TRow>(
rows: readonly TRow[],
values: Record<string, unknown>,
selection?: FilterSelection,
): TRow[];
/**
* A TanStack `ColumnDef.filterFn`. Returns a *function*, so nothing has to be
* registered on the table via `filterFns: { inDateRange, arrSome }`.
*/
filterFn(
key: string,
):
| ((
row: { getValue: (id: string) => unknown },
columnId: string,
value: unknown,
) => boolean)
| undefined;
/** Validate and coerce untrusted input (AI structured output, MCP args). */
coerce(raw: Record<string, unknown>): Partial<TValues>;
}
// ── Deriving specs ──────────────────────────────────────────────────────────
function specFromDescriptor(
key: string,
column: Pick<ColumnDescriptor, "kind" | "filter"> &
Partial<Pick<Extract<ColumnDescriptor, { kind: "array" }>, "arrayItem">>,
): FilterSpec | null {
const { filter } = column;
if (!filter) return null;
const spec: FilterSpec = {
key,
type: filter.type,
kind: column.kind,
};
if (column.kind === "array" && column.arrayItem) {
spec.itemKind = column.arrayItem.kind;
}
if (filter.options) {
spec.options = filter.options.map((option) => option.value);
}
if (filter.min !== undefined) spec.min = filter.min;
if (filter.max !== undefined) spec.max = filter.max;
return spec;
}
function isSchemaJSON(source: FilterSource): source is SchemaJSON {
return (
!Array.isArray(source) &&
typeof source === "object" &&
source !== null &&
Array.isArray((source as SchemaJSON).columns)
);
}
function toSpecs(source: FilterSource): FilterSpec[] {
if (Array.isArray(source)) {
return [...(source as readonly FilterSpec[])];
}
if (isSchemaJSON(source)) {
const specs: FilterSpec[] = [];
for (const column of source.columns) {
const spec = specFromDescriptor(column.key, column);
if (spec) specs.push(spec);
}
return specs;
}
const specs: FilterSpec[] = [];
for (const column of resolveColumns(source as TableSchemaDefinition)) {
const spec = specFromDescriptor(column.key, column);
if (spec) specs.push(spec);
}
return specs;
}
function isSelected(key: string, selection?: FilterSelection): boolean {
if (selection?.exclude?.includes(key)) return false;
if (selection?.only && !selection.only.includes(key)) return false;
return true;
}
// ── Coercion ────────────────────────────────────────────────────────────────
function coerceValue(spec: FilterSpec, raw: unknown): unknown {
// Round-tripping through `normalize` is the coercion: if the declared
// semantics cannot make an op out of it, it is not a usable filter value.
const op = normalize(spec, raw);
if (!op) return undefined;
switch (op.op) {
case "substring":
return op.value;
case "equals":
return op.value;
case "oneOf":
case "overlaps": {
// Drop values outside the declared option set — an LLM will happily
// invent an enum member that does not exist.
if (!spec.options) return op.values;
const allowed = op.values.filter((value) =>
spec.options!.some((option) => String(option) === String(value)),
);
return allowed.length > 0 ? allowed : undefined;
}
case "numberRange": {
// Clamp to the declared bounds. `coerce` is the untrusted-input entry
// point, and an LLM asked for "slow requests" will happily produce
// `[0, 99999]` against a slider declared `{ min: 0, max: 5000 }`.
const lower = spec.min ?? -Infinity;
const upper = spec.max ?? Infinity;
const min = Math.min(Math.max(op.min, lower), upper);
const max = Math.min(Math.max(op.max, lower), upper);
return [min, max];
}
case "dateRange":
return [op.from, op.to];
}
}
// ── Entry point ─────────────────────────────────────────────────────────────
/**
* Build the one interpretation of a table's filter semantics.
*
* Every engine — SQL, in-memory, TanStack — goes through this, so a column's
* declared `(FilterType, ColKind)` pair is honoured identically everywhere
* instead of being re-derived from whatever the value happened to look like at
* runtime.
*
* Built from a table schema definition, the result knows which columns are
* filterable and what each accepts (`FilterValues`); from `SchemaJSON` or
* specs it is untyped.
*/
export function defineFilters<TSchema extends TableSchemaDefinition>(
source: TSchema,
): Filters<FilterValues<TSchema>>;
export function defineFilters(source: FilterSource): Filters;
export function defineFilters(source: FilterSource): Filters {
const specs = toSpecs(source);
const byKey = new Map(specs.map((spec) => [spec.key, spec]));
const plan = (
values: Record<string, unknown>,
selection?: FilterSelection,
): FilterOp[] => {
const ops: FilterOp[] = [];
for (const [key, value] of Object.entries(values)) {
if (!isSelected(key, selection)) continue;
const spec = byKey.get(key);
if (!spec) continue;
const op = normalize(spec, value);
if (op) ops.push(op);
}
return ops;
};
const matches = (
values: Record<string, unknown>,
row: unknown,
selection?: FilterSelection,
): boolean =>
plan(values, selection).every((op) =>
evaluateOp(op, getValueAtKey(row, op.key)),
);
return {
specs,
spec: (key) => byKey.get(key),
plan,
matches,
apply: (rows, values, selection) => {
const ops = plan(values, selection);
if (ops.length === 0) return [...rows];
return rows.filter((row) =>
ops.every((op) => evaluateOp(op, getValueAtKey(row, op.key))),
);
},
filterFn: (key) => {
const spec = byKey.get(key);
if (!spec) return undefined;
return (row, columnId, value) => {
const op = normalize(spec, value);
// An inactive filter matches everything, which is what TanStack expects
// when a column filter is present but empty.
if (!op) return true;
return evaluateOp(op, row.getValue(columnId));
};
},
coerce: (raw) => {
const result: Record<string, unknown> = {};
for (const spec of specs) {
const value = raw[spec.key];
if (!isActive(value)) continue;
const coerced = coerceValue(spec, value);
if (coerced !== undefined) result[spec.key] = coerced;
}
return result;
},
};
}
export type { Scalar as FilterScalar };
src/lib/table-schema/generators/columns.tsx
"use client";
import {
DataTableCellBadge,
DataTableCellBar,
DataTableCellBoolean,
DataTableCellCode,
DataTableCellGauge,
DataTableCellHeatmap,
DataTableCellLevelIndicator,
DataTableCellNumber,
DataTableCellStar,
DataTableCellStatusCode,
DataTableCellText,
DataTableCellTimestamp,
} from "@/components/data-table/data-table-cell";
import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header";
import { Checkbox } from "@/components/ui/checkbox";
import { defineFilters } from "@/lib/filters";
import type { DataTableFeatures } from "@/lib/table/features";
import type { ColumnDef, RowData } from "@tanstack/react-table";
import type { ComponentProps, JSX } from "react";
import { resolveColumns } from "../col";
import type { DisplayDescriptor, TableSchemaDefinition } from "../types";
/**
* Render the cell based on the display config.
*/
function renderCell(
display: DisplayDescriptor,
value: unknown,
context?: { min: number; max: number },
): JSX.Element | null {
const fallback = <DataTableCellText value={String(value ?? "")} />;
const colorMap = "colorMap" in display ? display.colorMap : undefined;
switch (display.type) {
case "text": {
const hex = colorMap?.[String(value)];
return typeof value === "string" || typeof value === "number" ? (
<DataTableCellText value={value} color={hex} />
) : (
fallback
);
}
case "code": {
const hex = colorMap?.[String(value)];
return typeof value === "string" || typeof value === "number" ? (
<DataTableCellCode value={value} color={hex} />
) : (
fallback
);
}
case "number": {
const hex = colorMap?.[String(value)];
return typeof value === "number" ? (
<DataTableCellNumber value={value} unit={display.unit} color={hex} />
) : (
fallback
);
}
case "timestamp": {
const hex = colorMap?.[String(value)];
return value instanceof Date ||
typeof value === "string" ||
typeof value === "number" ? (
<DataTableCellTimestamp date={value} color={hex} />
) : (
fallback
);
}
case "badge": {
if (Array.isArray(value)) {
return (
<div className="flex-no-wrap flex gap-1">
{value.map((item, i) => (
<DataTableCellBadge
key={i}
value={item}
color={colorMap?.[String(item)]}
/>
))}
</div>
);
}
const hex = colorMap?.[String(value)];
return typeof value === "string" || typeof value === "number" ? (
<DataTableCellBadge value={value} color={hex} />
) : (
fallback
);
}
case "boolean": {
const hex = colorMap?.[String(value)];
return typeof value === "boolean" ? (
<DataTableCellBoolean value={value} color={hex} />
) : (
fallback
);
}
case "star": {
return typeof value === "boolean" ? (
<DataTableCellStar value={value} />
) : (
fallback
);
}
case "status-code": {
const hex = colorMap?.[String(value)];
return typeof value === "number" ? (
<DataTableCellStatusCode value={value} color={hex} />
) : (
fallback
);
}
case "level-indicator": {
const hex = colorMap?.[String(value)];
return typeof value === "string" ? (
<DataTableCellLevelIndicator value={value} color={hex} />
) : (
fallback
);
}
case "heatmap": {
const { min = 0, max = 100 } = context ?? {};
return typeof value === "number" ? (
<DataTableCellHeatmap
value={value}
min={min}
max={max}
unit={display.unit}
color={display.color}
/>
) : (
fallback
);
}
case "bar": {
const { min = 0, max = 100 } = context ?? {};
return typeof value === "number" ? (
<DataTableCellBar
value={value}
min={min}
max={max}
unit={display.unit}
color={display.color}
/>
) : (
fallback
);
}
case "gauge": {
const { min = 0, max = 100 } = context ?? {};
return typeof value === "number" ? (
<DataTableCellGauge
value={value}
min={min}
max={max}
unit={display.unit}
color={display.color}
/>
) : (
fallback
);
}
}
}
/**
* The one interpretation of `.size()` / `.minSize()` / `.resizable()`:
*
* - `.minSize(px)` — a flexing column with a floor: it absorbs the table's
* leftover width but never compresses below `px`.
* - `.size(px)` without `.resizable()` — locked: min/max pin the rendered
* width, so only an unsized column can flex.
* - `.size(px)` with `.resizable()` — `px` is the initial width only.
*/
function sizingFor(config: {
size?: number;
minSize?: number;
resizable: boolean;
}): { size?: number; minSize?: number; maxSize?: number } {
if (config.minSize !== undefined) {
return {
minSize: config.minSize,
...(config.size !== undefined ? { size: config.size } : {}),
};
}
if (config.size === undefined) return {};
if (config.resizable) return { size: config.size };
return { size: config.size, minSize: config.size, maxSize: config.size };
}
/**
* The header checkbox's third state, spelled for both libraries.
*
* `ui/checkbox` resolves from whichever library the project was initialized
* with, and they disagree: Radix takes `checked="indeterminate"`, Base UI
* types `checked` as a boolean and takes a separate `indeterminate` flag.
* Setting both is the only form that compiles and renders on either — hence
* the cast, the one place this file steps around a props type. In the partial
* state Radix does receive the extra `indeterminate` and forwards it to its
* `<button>` as `indeterminate="true"`, which is inert; setting it only when
* it applies is what avoids `indeterminate="false"`, which React warns about
* on a non-boolean attribute.
*/
function selectionState(allSelected: boolean, someSelected: boolean) {
const indeterminate = !allSelected && someSelected;
return {
checked: allSelected || (someSelected && "indeterminate"),
...(indeterminate ? { indeterminate: true } : {}),
} as ComponentProps<typeof Checkbox>;
}
/**
* Generate ColumnDef[] from a table schema definition.
*
* Rules:
* - Dotted keys (e.g. "timing.dns") → id + accessorFn
* - Non-dotted keys → accessorKey
* - Sortable columns get DataTableColumnHeader; others get a plain string header
* - filterFn comes from the shared filter-semantics module
* - Cell renders via built-in display components or the "custom" cell function
* - meta.label is always set; meta.hidden reflects .hidden() calls
*
* Composite/virtual columns that span multiple fields must be appended manually:
* @example
* ```ts
* const columns = [
* ...generateColumns(tableSchema),
* { id: "timing", header: ..., cell: ..., size: 130 },
* ];
* ```
*/
export function generateColumns<TData extends RowData>(
schema: TableSchemaDefinition,
): ColumnDef<DataTableFeatures, TData>[] {
// One interpretation of filter semantics, shared with the SQL and in-memory
// engines. `filterFn` returns a *function*, so the consuming table no longer
// has to register `filterFns: { inDateRange, arrSome }` — a contract that was
// undocumented outside a comment and silently broke filtering when missed.
const filters = defineFilters(schema);
return resolveColumns(schema).map((config) => {
const { key } = config;
// Select column — checkbox header + cell
if (config.kind === "select") {
return {
id: key,
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
{...selectionState(
table.getIsAllPageRowsSelected(),
table.getIsSomePageRowsSelected(),
)}
onCheckedChange={(value: unknown) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
className="shadow-none"
/>
</div>
),
cell: ({ row }) => (
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="shadow-none"
/>
</div>
),
enableSorting: false,
enableHiding: false,
enableResizing: false,
...sizingFor(config),
meta: { label: config.label, kind: "select", hidden: config.hidden },
} as ColumnDef<DataTableFeatures, TData>;
}
const isDotted = key.includes(".");
const filterFn = filters.filterFn(key);
const header = config.hideHeader
? () => <span className="sr-only">{config.label}</span>
: config.sortable
? ({
column,
}: {
column: Parameters<typeof DataTableColumnHeader>[0]["column"];
}) => <DataTableColumnHeader column={column} title={config.label} />
: config.label;
const needsMinMax =
config.display.type === "heatmap" ||
config.display.type === "bar" ||
config.display.type === "gauge";
const customCell = config.renderers.cell;
const cell = ({
getValue,
row,
column,
}: {
getValue: () => unknown;
row: { original: TData };
column: { getFacetedMinMaxValues?: () => [number, number] | undefined };
}) => {
// A custom renderer overrides the descriptor's display. The descriptor
// still carries a real display type, which is what the sheet and
// `toJSON()` fall back to.
if (customCell) return customCell(getValue(), row.original);
if (needsMinMax) {
const display = config.display as {
min?: number;
max?: number;
};
const faceted = column.getFacetedMinMaxValues?.();
const min = faceted?.[0] ?? display.min ?? 0;
const max = faceted?.[1] ?? display.max ?? 100;
return renderCell(config.display, getValue(), { min, max });
}
return renderCell(config.display, getValue());
};
const meta = {
label: config.label,
hidden: config.hidden,
kind: config.kind,
};
const base = {
header,
cell,
enableResizing: config.resizable,
// Not just the header: with the default (`true`) a column that never
// opted in could still be sorted through state, e.g. a URL param.
enableSorting: config.sortable,
...(config.enableHiding === false ? { enableHiding: false } : {}),
...(filterFn ? { filterFn } : {}),
...sizingFor(config),
meta,
};
if (isDotted) {
return {
...base,
id: key,
accessorFn: (row: TData) => (row as Record<string, unknown>)[key],
} as ColumnDef<DataTableFeatures, TData>;
}
return {
...base,
accessorKey: key,
} as ColumnDef<DataTableFeatures, TData>;
});
}
src/lib/table-schema/generators/filter-fields.tsx
// Imported from its file, not the cell barrel: this generator runs on the
// server too (the filter schema reads it), and the barrel drags in cells that
// use client-only hooks.
import { DataTableCellLevelIndicator } from "@/components/data-table/data-table-cell/data-table-cell-level-indicator";
import type {
CheckboxOptionProps,
DataTableFilterField,
Option,
} from "@/components/data-table/types";
import type { JSX } from "react";
import { fromPresetDescriptor, resolveColumns } from "../col";
import type { ResolvedColumn, TableSchemaDefinition } from "../types";
/**
* The checkbox option a display picks when the column supplied no
* `component` of its own. A `level-indicator` column shows the same dot it
* renders in its cells (after the label, clear of the checkbox), so the
* filter sidebar reads like the table — which
* is why it is limited to the kinds whose cells draw the dot (`renderCell`
* falls back to plain text for anything but a string).
*/
function defaultFilterComponent(
config: ResolvedColumn,
options?: Option[],
): ((props: CheckboxOptionProps) => JSX.Element | null) | undefined {
if (config.display.type !== "level-indicator") return undefined;
if (config.kind !== "enum" && config.kind !== "string") return undefined;
const colorMap = config.display.colorMap;
// The dot follows the label, so every option is sized to the widest label
// to keep the dots in one column. The filter passes the field's options,
// which win over the schema's: a field filled from facets after this
// component was created only has them there.
return function LevelOption({
label,
value,
options: rendered = options,
}: CheckboxOptionProps) {
return (
<DataTableCellLevelIndicator
value={String(value)}
label={label}
color={colorMap?.[String(value)]}
showLabel
dotPosition="end"
alignLabels={widestLabelCandidates(rendered)}
/>
);
};
}
/** Each option renders a sizer per label, so a long list is cut to a few. */
const MAX_ALIGN_LABELS = 8;
// Every option of a filter asks for the same list on every render, so the
// shortlist is kept per options array — a large facet is sorted once, not once
// per row.
const candidatesCache = new WeakMap<Option[], string[]>();
/**
* The labels that might be the widest. Character count only shortlists them —
* the browser does the measuring — so a miss costs alignment, never text.
*/
export function widestLabelCandidates(options?: Option[]): string[] {
if (!options) return [];
const cached = candidatesCache.get(options);
if (cached) return cached;
const candidates = [...new Set(options.map((o) => o.label))]
.sort((a, b) => b.length - a.length)
.slice(0, MAX_ALIGN_LABELS);
candidatesCache.set(options, candidates);
return candidates;
}
/**
* Generate DataTableFilterField[] from a table schema definition.
*
* Only includes fields where filter !== null.
* Order follows schema definition order (JS object key insertion order).
*
* Options for checkbox fields are auto-derived from col.enum(values) or
* col.boolean() if not explicitly provided via filterable("checkbox", { options }).
*/
export function generateFilterFields<TData>(
schema: TableSchemaDefinition,
): DataTableFilterField<TData>[] {
const result: DataTableFilterField<TData>[] = [];
for (const config of resolveColumns(schema)) {
const { key, filter, label, kind } = config;
if (!filter) continue;
const base = {
label,
value: key as keyof TData,
defaultOpen: filter.defaultOpen || undefined,
commandDisabled: filter.commandDisabled || undefined,
};
switch (filter.type) {
case "input": {
result.push({ ...base, type: "input" });
break;
}
case "timerange": {
result.push({
...base,
type: "timerange",
presets: filter.presets?.map(fromPresetDescriptor),
});
break;
}
case "checkbox": {
// Derive options if not explicitly provided
let options = filter.options;
if (!options) {
if (config.kind === "enum") {
options = config.enumValues.map((v) => ({ label: v, value: v }));
} else if (kind === "boolean") {
options = [
{ label: "Yes", value: true },
{ label: "No", value: false },
];
} else if (
config.kind === "array" &&
config.arrayItem.kind === "enum"
) {
options = config.arrayItem.enumValues.map((v) => ({
label: v,
value: v,
}));
}
}
result.push({
...base,
type: "checkbox",
options,
component:
config.renderers.filterComponent ??
defaultFilterComponent(config, options),
});
break;
}
case "slider": {
const displayUnit =
"unit" in config.display ? config.display.unit : undefined;
result.push({
...base,
type: "slider",
min: filter.min ?? 0,
max: filter.max ?? 100,
unit: filter.unit ?? displayUnit,
});
break;
}
}
}
return result;
}
src/lib/table-schema/generators/filter-schema.ts
import {
ARRAY_DELIMITER,
RANGE_DELIMITER,
SLIDER_DELIMITER,
} from "@/lib/delimiters";
import { createSchema, field } from "@/lib/store/schema";
import type {
FieldBuilder,
Schema,
SchemaDefinition,
} from "@/lib/store/schema";
import { resolveColumns } from "../col";
import type { ColBuilder, TableSchemaDefinition } from "../types";
/**
* Extract keys from a TableSchemaDefinition where the column is filterable.
* A column is filterable when its filter type `F` is not `never`.
* Uses `[F] extends [never]` to avoid distribution issues with `any`.
*/
type FilterableKeys<T extends TableSchemaDefinition> = {
[K in keyof T]: T[K] extends ColBuilder<infer _T, infer F>
? [F] extends [never]
? never
: K
: never;
}[keyof T];
// Extract T and F separately — TS struggles to infer both at once from ColBuilder
type GetColValue<B> = B extends ColBuilder<infer T, any> ? T : never;
type GetColFilter<B> = B extends ColBuilder<any, infer F> ? F : never;
/**
* Map a single ColBuilder to the correct FieldBuilder type based on its
* value type `T` and filter type `F`.
*/
type InferFilterFieldType<B> = [GetColFilter<B>] extends [never]
? never
: GetColFilter<B> extends "input"
? GetColValue<B> extends string
? FieldBuilder<string | null>
: GetColValue<B> extends number
? FieldBuilder<number | null>
: FieldBuilder<unknown>
: GetColFilter<B> extends "slider"
? FieldBuilder<(number | null)[]>
: GetColFilter<B> extends "timerange"
? FieldBuilder<(Date | null)[]>
: GetColFilter<B> extends "checkbox"
? GetColValue<B> extends (infer U)[]
? FieldBuilder<(U | null)[]>
: FieldBuilder<(GetColValue<B> | null)[]>
: FieldBuilder<unknown>;
/** The generated filter definition — preserves the filterable keys from `T`. */
type GeneratedFilterDef<T extends TableSchemaDefinition> = {
[K in FilterableKeys<T>]: InferFilterFieldType<T[K]>;
};
function buildFilterDefinition(
schema: TableSchemaDefinition,
): SchemaDefinition {
const definition: SchemaDefinition = {};
for (const config of resolveColumns(schema)) {
const { key, kind, filter } = config;
if (!filter) continue;
switch (filter.type) {
case "input": {
if (kind === "string") {
definition[key] = field.string();
} else if (kind === "number") {
definition[key] = field.number();
}
break;
}
case "checkbox": {
if (config.kind === "enum") {
definition[key] = field.array(field.stringLiteral(config.enumValues));
} else if (kind === "number") {
definition[key] = field
.array(field.number())
.delimiter(ARRAY_DELIMITER);
} else if (kind === "boolean") {
definition[key] = field
.array(field.boolean())
.delimiter(ARRAY_DELIMITER);
} else if (
config.kind === "array" &&
config.arrayItem.kind === "enum"
) {
definition[key] = field.array(
field.stringLiteral(config.arrayItem.enumValues),
);
} else if (config.kind === "array") {
// Non-enum array item — filter on the item's own scalar type.
definition[key] =
config.arrayItem.kind === "number"
? field.array(field.number()).delimiter(ARRAY_DELIMITER)
: field.array(field.string()).delimiter(ARRAY_DELIMITER);
}
break;
}
case "slider": {
definition[key] = field
.array(field.number())
.delimiter(SLIDER_DELIMITER);
break;
}
case "timerange": {
definition[key] = field
.array(field.timestamp())
.delimiter(RANGE_DELIMITER);
break;
}
}
}
return definition;
}
/**
* Generate a BYOS filter schema from a table schema definition.
*
* Each filterable column maps to the appropriate field.* builder:
* - col.string() + input → field.string()
* - col.number() + input → field.number()
* - col.number() + slider → field.array(field.number()).delimiter(SLIDER_DELIMITER)
* - col.number() + checkbox → field.array(field.number()).delimiter(ARRAY_DELIMITER)
* - col.boolean() + checkbox → field.array(field.boolean()).delimiter(ARRAY_DELIMITER)
* - col.timestamp()+ timerange→ field.array(field.timestamp()).delimiter(RANGE_DELIMITER)
* - col.enum(v) + checkbox → field.array(field.stringLiteral(v))
* - col.array(col.enum(v)) + checkbox → field.array(field.stringLiteral(v))
*
* Non-filterable fields are excluded. Pass `extraFields` for pagination,
* sorting, and other non-filter state.
*
* @example
* ```ts
* // Without extra fields (filter fields only)
* const filterSchema = generateFilterSchema(tableSchema.definition);
*
* // With extra fields (recommended)
* const filterSchema = generateFilterSchema(tableSchema.definition, {
* sort: field.sort(),
* uuid: field.string(),
* live: field.boolean().default(false),
* size: field.number().default(40),
* });
* ```
*/
export function generateFilterSchema<T extends TableSchemaDefinition>(
schema: T,
): Schema<GeneratedFilterDef<T>>;
export function generateFilterSchema<
T extends TableSchemaDefinition,
E extends SchemaDefinition,
>(schema: T, extraFields: E): Schema<GeneratedFilterDef<T> & E>;
export function generateFilterSchema<
T extends TableSchemaDefinition,
E extends SchemaDefinition,
>(schema: T, extraFields?: E) {
const generated = buildFilterDefinition(schema);
const merged = extraFields ? { ...generated, ...extraFields } : generated;
return createSchema(merged);
}
src/lib/table-schema/generators/sheet-fields.ts
import type { SheetField } from "@/components/data-table/types";
import { resolveColumns } from "../col";
import type { TableSchemaDefinition } from "../types";
/**
* Generate SheetField[] from a table schema definition.
*
* Only includes fields where sheet !== null (.sheet() was called).
* Sheet type is derived from the filter type, or "readonly" if not filterable.
* Sheet label falls back to the column label if not overridden in .sheet({ label }).
*/
export function generateSheetFields<TData>(
schema: TableSchemaDefinition,
): SheetField<TData>[] {
const result: SheetField<TData>[] = [];
for (const config of resolveColumns(schema)) {
const sheetConfig = config.sheet;
if (sheetConfig === null) continue;
// Derive sheet type from filter type, or "readonly" if not filterable
const sheetType: SheetField<TData>["type"] =
config.filter?.type ?? "readonly";
result.push({
id: config.key as keyof TData,
label: sheetConfig.label ?? config.label,
type: sheetType,
// The descriptor's display is always a real serializable display, even
// when a custom cell renderer was supplied — no per-kind fallback needed.
display: config.display,
component: config.renderers
.sheetComponent as SheetField<TData>["component"],
condition: config.renderers
.sheetCondition as SheetField<TData>["condition"],
className: sheetConfig.className,
skeletonClassName: sheetConfig.skeletonClassName,
});
}
return result;
}
src/components/data-table/data-table-auto.tsx
"use client";
import { DataTableFilterCommand } from "@/components/data-table/data-table-filter-command";
import { DataTableInfinite } from "@/components/data-table/data-table-infinite";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { MemoizedDataTableSheetContent } from "@/components/data-table/data-table-sheet/data-table-sheet-content";
import { DataTableSheetDetails } from "@/components/data-table/data-table-sheet/data-table-sheet-details";
import type { SheetField } from "@/components/data-table/types";
import { useMemoryAdapter } from "@/lib/store/adapters/memory";
import { DataTableStoreProvider } from "@/lib/store/provider/DataTableStoreProvider";
import { field } from "@/lib/store/schema";
import type { SchemaDefinition } from "@/lib/store/schema/types";
import {
createTableSchema,
generateColumns,
generateFilterFields,
generateFilterSchema,
generateSheetFields,
getDefaultColumnVisibility,
} from "@/lib/table-schema";
import { inferSchemaFromJSON } from "@/lib/table-schema/infer";
import * as React from "react";
type AutoRow = Record<string, unknown>;
export interface DataTableAutoProps {
data: AutoRow[];
}
export function DataTableAuto({ data }: DataTableAutoProps) {
const schemaJson = React.useMemo(() => inferSchemaFromJSON(data), [data]);
const { definition } = React.useMemo(
() => createTableSchema.fromJSON(schemaJson),
[schemaJson],
);
const columns = React.useMemo(
() => generateColumns<AutoRow>(definition),
[definition],
);
const filterFields = React.useMemo(
() => generateFilterFields<AutoRow>(definition),
[definition],
);
const sheetFields = React.useMemo(
() => generateSheetFields<AutoRow>(definition),
[definition],
);
const defaultColumnVisibility = React.useMemo(
() => getDefaultColumnVisibility(definition),
[definition],
);
// Merge `sort` BEFORE createSchema so `defaults` cannot disagree with
// `definition` — splicing it in afterwards left `definition.sort` present
// while `defaults.sort` was missing.
const filterSchema = React.useMemo(
() => generateFilterSchema(definition, { sort: field.sort() }),
[definition],
);
const adapter = useMemoryAdapter(filterSchema.definition, { id: "auto" });
return (
<DataTableStoreProvider adapter={adapter}>
<DataTableAutoInner
data={data}
columns={columns}
filterFields={filterFields}
sheetFields={sheetFields}
defaultColumnVisibility={defaultColumnVisibility}
schema={filterSchema.definition}
/>
</DataTableStoreProvider>
);
}
const noop = () => Promise.resolve();
function DataTableAutoInner({
data,
columns,
filterFields,
sheetFields,
defaultColumnVisibility,
schema,
}: {
data: AutoRow[];
columns: React.ComponentProps<typeof DataTableInfinite<AutoRow>>["columns"];
filterFields: React.ComponentProps<
typeof DataTableInfinite<AutoRow>
>["filterFields"];
sheetFields: SheetField<AutoRow>[];
defaultColumnVisibility: Record<string, boolean>;
schema: SchemaDefinition;
}) {
return (
<DataTableInfinite
columns={columns}
data={data}
filterFields={filterFields}
defaultColumnVisibility={defaultColumnVisibility}
totalRowsFetched={data.length}
hasNextPage={false}
fetchNextPage={noop}
isFetching={false}
isLoading={false}
tableId="auto"
commandSlot={<DataTableFilterCommand schema={schema} tableId="auto" />}
sheetSlot={
<AutoSheetSlot sheetFields={sheetFields} totalRows={data.length} />
}
footerSlot={
<div className="text-muted-foreground text-sm">
powered by{" "}
<a
href="https://openstatus.dev"
target="_blank"
rel="noreferrer"
className="text-foreground hover:text-primary"
>
openstatus.dev
</a>
</div>
}
/>
);
}
function AutoSheetSlot({
sheetFields: fields,
totalRows,
}: {
sheetFields: SheetField<AutoRow>[];
totalRows: number;
}) {
const { table, rowSelection, isLoading, filterFields } = useDataTable<
AutoRow,
unknown
>();
const selectedRowKey = Object.keys(rowSelection)?.[0];
const selectedRow = React.useMemo(() => {
if (isLoading && !selectedRowKey) return undefined;
return table
.getCoreRowModel()
.flatRows.find((row) => row.id === selectedRowKey);
}, [selectedRowKey, isLoading, table]);
return (
<DataTableSheetDetails
title={
selectedRow ? String(Object.values(selectedRow.original)[0] ?? "") : ""
}
titleClassName="font-mono"
>
<MemoizedDataTableSheetContent
table={table}
data={selectedRow?.original}
filterFields={filterFields}
fields={fields}
metadata={{
totalRows,
filterRows: totalRows,
totalRowsFetched: totalRows,
}}
/>
</DataTableSheetDetails>
);
}
src/hooks/use-table-manifest.ts
"use client";
import {
fetchTableManifest,
type FetchManifestOptions,
type TableManifest,
} from "@/lib/table-schema";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
/**
* Load a table's manifest.
*
* ## Why this is separate from the data query
*
* The manifest cannot be fetched alongside the rows, because everything that
* fetches rows is *built from* it: the columns, the filter fields, and — the
* binding constraint — the URL-state adapter, whose parsers are derived from
* the schema. `useNuqsAdapter(schema, …)` needs a schema at first render, and a
* hook cannot be called conditionally, so the manifest has to resolve one level
* up from anything that consumes it.
*
* That is why the headless table is two components: an outer one that resolves
* the manifest, and an inner one that is only mounted once it has. This hook is
* the outer half.
*
* ## Avoiding the waterfall
*
* A round trip for the manifest before the first row request is a real cost.
* Two ways out, both supported by `initialManifest`:
*
* - **Snapshot at build time.** Check the manifest into the app and pass it.
* The query still revalidates in the background, so a schema change on the
* server is picked up without a redeploy, but nothing blocks on it.
* - **Prefetch on the server.** `fetchTableManifest` on the server component and
* hand the result down, or seed the React Query cache under `tableManifestKey`.
*
* With `initialManifest` the hook never suspends and the table renders on the
* first paint.
*/
/** The query key a manifest is cached under. Exported so a server can seed it. */
export function tableManifestKey(endpoint: string) {
return ["data-table-manifest", endpoint] as const;
}
export type UseTableManifestOptions = FetchManifestOptions & {
/**
* A manifest that is already known — a build-time snapshot, or one prefetched
* on the server. Used as the initial value; the query still revalidates.
*/
initialManifest?: TableManifest;
/**
* How long a manifest stays fresh. Schemas change on deploy, not per request,
* so this is long by default.
*/
staleTime?: number;
/** Set false to render only from `initialManifest` and never fetch. */
enabled?: boolean;
/**
* Retries before the query gives up. Defaults to 1: a malformed manifest is
* not transient — retrying cannot repair the endpoint's answer — but a single
* retry still covers a dropped connection.
*/
retry?: number | boolean;
};
const FIVE_MINUTES = 1000 * 60 * 5;
export function useTableManifest(
endpoint: string,
options?: UseTableManifestOptions,
): UseQueryResult<TableManifest, Error> {
const {
initialManifest,
staleTime = FIVE_MINUTES,
enabled = true,
retry = 1,
...fetchOptions
} = options ?? {};
return useQuery<TableManifest, Error>({
queryKey: tableManifestKey(endpoint),
queryFn: ({ signal }) =>
fetchTableManifest(endpoint, { ...fetchOptions, signal }),
...(initialManifest ? { initialData: initialManifest } : {}),
enabled,
staleTime,
retry,
refetchOnWindowFocus: false,
});
}
More from Data Table Filters
Data Table with FiltersblockData Table Row ActionsblockData Table Cell RenderersblockTimeline ChartblockData Table Drizzle ORM HelpersblockExample: infinite tableblockData Table Filter Command PaletteblockData Table AI Filter Command Paletteblock
Browse all components in this library →