Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import type {
AgentKey,
AuditEntry,
BillingSummary,
CellValue,
ColumnFormat,
ColumnMeta,
ColumnType,
Filter,
ImportColumnOverride,
ImportGroupOverride,
ImportPreview,
ImportResult,
InviteInfo,
Locale,
Member,
MetaResponse,
NormalizeMerge,
NormalizePlan,
PendingInvite,
PlanId,
PublicConfig,
Role,
Row,
RowsResponse,
Rule,
SessionResponse,
Sort,
Summaries,
SummaryResponse,
TableMeta,
Tightening,
TightenPlan,
TightenWalkPlan,
ViewDef,
VoiceAssignment,
Workspace,
} from '@wellformed/shared';
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = 'ApiError';
}
get isAuth(): boolean {
return this.status === 401;
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase();
const headers = new Headers(init.headers);
if (init.body !== undefined) headers.set('content-type', 'application/json');
let response: Response;
try {
response = await fetch(path, {
...init,
headers,
// The session lives in an httpOnly cookie; it must ride along.
credentials: 'same-origin',
});
} catch (cause) {
throw new ApiError(0, 'network', 'Cannot reach the Wellformed server.', cause);
}
if (response.status === 204) return undefined as T;
const text = await response.text();
const body: unknown = text ? safeJson(text) : null;
if (!response.ok) {
const shaped = body as { error?: string; message?: string; details?: unknown } | null;
throw new ApiError(
response.status,
shaped?.error ?? 'error',
shaped?.message ?? `${method} ${path} failed with ${response.status}.`,
shaped?.details,
);
}
return body as T;
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return { message: text };
}
}
const json = (body: unknown): RequestInit => ({ body: JSON.stringify(body) });
/**
* The workspace every data call is addressed to.
*
* One document shows one workspace — switching is a full navigation, see
* `state/route.ts` — so this is a property of the page, not something worth
* threading through every call site. `main.tsx` sets it once from the URL
* before anything renders.
*/
let activeSlug: string | null = null;
export function setActiveWorkspace(slug: string | null): void {
activeSlug = slug;
}
export function activeWorkspace(): string | null {
return activeSlug;
}
/** Build a workspace-scoped API path. Throws rather than silently calling nowhere. */
function w(path: string): string {
if (!activeSlug) {
throw new ApiError(0, 'no_workspace', 'No workspace is open.');
}
return `/api/w/${encodeURIComponent(activeSlug)}${path}`;
}
export interface RowsQuery {
table: string;
viewId?: string | null;
filters?: Filter[];
/** `undefined` keeps the view's sort; `null` explicitly clears it. */
sort?: Sort | null;
/** `undefined` keeps the view's hidden columns; a list replaces them whole. */
hidden?: string[];
search?: string;
limit?: number;
offset?: number;
}
function rowsUrl(query: RowsQuery): string {
const params = new URLSearchParams();
if (query.viewId) params.set('view', query.viewId);
if (query.filters?.length) params.set('filters', JSON.stringify(query.filters));
if (query.sort !== undefined) params.set('sort', JSON.stringify(query.sort));
if (query.hidden !== undefined) params.set('hidden', JSON.stringify(query.hidden));
if (query.search) params.set('q', query.search);
params.set('limit', String(query.limit ?? 200));
params.set('offset', String(query.offset ?? 0));
return w(`/tables/${encodeURIComponent(query.table)}/rows?${params}`);
}
/** The aggregate row over the same rows, so it takes the same scope minus the page. */
export interface SummaryQuery {
table: string;
viewId?: string | null;
filters?: Filter[];
/** The grid's hidden columns, so a search scopes to the same rows it shows. */
hidden?: string[];
search?: string;
summaries: Summaries;
}
function summaryUrl(query: SummaryQuery): string {
const params = new URLSearchParams();
if (query.viewId) params.set('view', query.viewId);
if (query.filters?.length) params.set('filters', JSON.stringify(query.filters));
if (query.hidden !== undefined) params.set('hidden', JSON.stringify(query.hidden));
if (query.search) params.set('q', query.search);
params.set('summaries', JSON.stringify(query.summaries));
return w(`/tables/${encodeURIComponent(query.table)}/summary?${params}`);
}
export interface WriteResult {
row: Row;
workflows: string[];
}
export interface DeleteResult {
restore: Record<string, CellValue>;
label: string;
}
/**
* A signup makes an account and nothing else; the workspace comes after,
* from the picker.
*/
export interface SignupInput {
email: string;
name: string;
password: string;
/** The landing page impression this signup came from, when there was one. */
voice?: string;
}
/** What to build into a new workspace: nothing, the demo's tables, or the demo whole. */
export type DemoChoice = 'none' | 'structure' | 'full';
/**
* What to read: pasted text, or an uploaded file's bytes as base64.
*
* Exactly one of the two, and the server refuses both together rather than
* picking. `headerRow`, `groupRow`, `sheet` and `columns` are the guesses the
* preview makes and the operator can overrule; sending none means "guess
* again". `groupRow: null` turns the group fold off explicitly.
*/
export interface ImportRequest {
csv?: string;
file?: { name: string; data: string };
headerRow?: number;
groupRow?: number | null;
/** Trailing totals rows to leave out; 0 keeps every row. */
footerRows?: number;
sheet?: string;
columns?: ImportColumnOverride[];
/** Corrections to the group headings; one renames a whole group's columns. */
groups?: ImportGroupOverride[];
}
export const api = {
health: () => request<{ ok: boolean }>('/api/health'),
/** What this install offers, readable with no session at all. */
publicConfig: () => request<PublicConfig>('/api/public/config'),
/** Which lines of the landing page this visit gets. See `state/voice.ts`. */
voice: () => request<VoiceAssignment>('/api/public/voice'),
/** A person, not a crawler, looked at it. Fire and forget. */
voiceSeen: (id: string) =>
request<void>(`/api/public/voice/${encodeURIComponent(id)}/seen`, { method: 'POST' }),
// ---- the account, installation-wide -------------------------------------
me: () => request<SessionResponse>('/api/auth/me'),
login: (email: string, password: string) =>
request<SessionResponse>('/api/auth/login', { method: 'POST', ...json({ email, password }) }),
signup: (input: SignupInput) =>
request<SessionResponse>('/api/auth/signup', { method: 'POST', ...json(input) }),
logout: () => request<{ ok: true }>('/api/auth/logout', { method: 'POST' }),
renameAccount: (name: string) =>
request<SessionResponse>('/api/auth/me', { method: 'PATCH', ...json({ name }) }),
changePassword: (current: string, next: string) =>
request<{ ok: true }>('/api/auth/password', { method: 'POST', ...json({ current, next }) }),
// ---- locked out, and confirming the address -----------------------------
forgotPassword: (email: string) =>
request<{ ok: true; delivery: 'email' | 'none' }>('/api/auth/forgot', {
method: 'POST',
...json({ email }),
}),
readReset: (token: string) =>
request<{ email: string; name: string; expiresAt: string }>(
`/api/auth/reset/${encodeURIComponent(token)}`,
),
resetPassword: (token: string, password: string) =>
request<SessionResponse>(`/api/auth/reset/${encodeURIComponent(token)}`, {
method: 'POST',
...json({ password }),
}),
sendVerification: () =>
request<{ ok: true; delivery: 'email' | 'none' | 'already' }>('/api/auth/verify', {
method: 'POST',
}),
confirmEmail: (token: string) =>
request<{ ok: true; email: string }>(`/api/auth/verify/${encodeURIComponent(token)}`, {
method: 'POST',
}),
// ---- workspaces ---------------------------------------------------------
workspaces: () => request<{ workspaces: Workspace[] }>('/api/workspaces'),
createWorkspace: (input: { name: string; slug?: string; demo?: DemoChoice; locale?: Locale }) =>
request<{ workspace: Workspace }>('/api/workspaces', { method: 'POST', ...json(input) }),
updateWorkspace: (input: { name?: string; slug?: string; currency?: string; locale?: Locale }) =>
request<{ workspace: Workspace }>(w('/settings'), { method: 'PATCH', ...json(input) }),
deleteWorkspace: (slug: string) =>
request<{ ok: true }>(w(`/settings?confirm=${encodeURIComponent(slug)}`), { method: 'DELETE' }),
leaveWorkspace: () => request<{ ok: true }>(w('/leave'), { method: 'POST' }),
exportPath: () => w('/export'),
// ---- people in this workspace -------------------------------------------
members: () => request<{ members: Member[] }>(w('/members')),
updateMember: (id: string, input: { role: Role }) =>
request<{ member: Member }>(w(`/members/${encodeURIComponent(id)}`), {
method: 'PATCH',
...json(input),
}),
removeMember: (id: string) =>
request<{ ok: true }>(w(`/members/${encodeURIComponent(id)}`), { method: 'DELETE' }),
invites: () => request<{ invites: PendingInvite[] }>(w('/invites')),
createInvite: (input: { email: string; role: Role }) =>
request<{ invite: PendingInvite }>(w('/invites'), { method: 'POST', ...json(input) }),
revokeInvite: (id: string) =>
request<{ ok: true }>(w(`/invites/${encodeURIComponent(id)}`), { method: 'DELETE' }),
readInvite: (token: string) =>
request<{ invite: InviteInfo }>(`/api/invites/${encodeURIComponent(token)}`),
acceptInvite: (token: string, input: { name?: string; password?: string } = {}) =>
request<{ workspace: { slug: string; name: string; role: Role } }>(
`/api/invites/${encodeURIComponent(token)}/accept`,
{ method: 'POST', ...json(input) },
),
// ---- keys an AI assistant works through ---------------------------------
agentKeys: () => request<{ keys: AgentKey[] }>(w('/keys')),
/** The response carries `secret`, and it is the only time it ever will. */
createAgentKey: (input: { label: string; ceiling: Role; expiresInDays?: number | null }) =>
request<{ key: AgentKey }>(w('/keys'), { method: 'POST', ...json(input) }),
revokeAgentKey: (id: string) =>
request<{ ok: true }>(w(`/keys/${encodeURIComponent(id)}`), { method: 'DELETE' }),
// ---- what this workspace pays for ---------------------------------------
billing: () => request<BillingSummary>(w('/billing')),
checkout: (plan: PlanId, billingEmail?: string) =>
request<{ url: string }>(w('/billing/checkout'), {
method: 'POST',
...json({ plan, billingEmail }),
}),
setBillingContact: (billingEmail: string) =>
request<{ billingEmail: string | null }>(w('/billing/contact'), {
method: 'PATCH',
...json({ billingEmail }),
}),
billingPortal: () => request<{ url: string }>(w('/billing/portal')),
// ---- getting something into an empty workspace --------------------------
previewImport: (input: ImportRequest & { table?: string }) =>
request<ImportPreview>(w('/import/preview'), { method: 'POST', ...json(input) }),
importSheet: (
input: ImportRequest & {
/** Append into this table, or omit to create one with these names. */
table?: string;
name?: string;
label?: string;
},
) => request<ImportResult>(w('/import'), { method: 'POST', ...json(input) }),
// ---- inside the open workspace ------------------------------------------
meta: () => request<MetaResponse>(w('/meta')),
rows: (query: RowsQuery) => request<RowsResponse>(rowsUrl(query)),
summary: (query: SummaryQuery) => request<SummaryResponse>(summaryUrl(query)),
createRow: (table: string, values: Record<string, unknown>, id?: string) =>
request<WriteResult>(w(`/tables/${encodeURIComponent(table)}/rows`), {
method: 'POST',
...json(id ? { values, id } : { values }),
}),
updateRow: (table: string, id: string, values: Record<string, unknown>) =>
request<WriteResult>(w(`/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`), {
method: 'PATCH',
...json({ values }),
}),
deleteRow: (table: string, id: string) =>
request<DeleteResult>(w(`/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`), {
method: 'DELETE',
}),
/** Labels only — the server derives every identifier. */
createTable: (input: { label: string; displayLabel?: string; group?: string }) =>
request<{ table: TableMeta }>(w('/tables'), { method: 'POST', ...json(input) }),
/** One flat order for all tables; the sidebar's groups are runs within it. */
reorderTables: (order: string[]) =>
request<{ ok: true }>(w('/tables/reorder'), { method: 'POST', ...json({ order }) }),
/** The label, row-label column and sidebar group; the SQL name is not up for changing. */
updateTable: (table: string, input: { label?: string; display?: string; group?: string | null }) =>
request<{ table: TableMeta }>(w(`/tables/${encodeURIComponent(table)}`), {
method: 'PATCH',
...json(input),
}),
dropTable: (table: string) =>
request<{ ok: true }>(w(`/tables/${encodeURIComponent(table)}`), { method: 'DELETE' }),
addColumn: (
table: string,
input: {
/** What the column is called; the identifier is derived from it. */
label: string;
type: ColumnType;
options?: string[];
target?: string;
expr?: string;
format?: ColumnFormat | null;
required?: boolean;
unique?: boolean;
sequence?: string | null;
},
) =>
request<{ column: ColumnMeta }>(w(`/tables/${encodeURIComponent(table)}/columns`), {
method: 'POST',
...json(input),
}),
/**
* What tightening a column would cost, before it costs it. Read-only, and
* asked again on every keystroke that changes the proposal.
*/
planTighten: (table: string, column: string, input: Tightening) =>
request<{ plan: TightenPlan }>(
w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/tighten/plan`),
{ method: 'POST', ...json(input) },
),
tighten: (table: string, column: string, input: Tightening) =>
request<{ column: ColumnMeta; plan: TightenPlan; cleared: number }>(
w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/tighten`),
{ method: 'POST', ...json(input) },
),
/**
* The tighten walk: every stored column looked at once, each with what its
* values say it really is. Read-only; the steps are applied through
* `normalize` and `tighten`, one column at a time.
*/
walkTighten: (table: string) =>
request<{ plan: TightenWalkPlan }>(w(`/tables/${encodeURIComponent(table)}/tighten/walk`), {
method: 'POST',
...json({}),
}),
/**
* The whole table read for spellings that are the same answer written
* differently. Read-only; the walk's merge step.
*/
planNormalize: (table: string) =>
request<{ plan: NormalizePlan }>(w(`/tables/${encodeURIComponent(table)}/normalize/plan`), {
method: 'POST',
...json({}),
}),
normalize: (table: string, column: string, merges: NormalizeMerge[]) =>
request<{ column: ColumnMeta; changed: number }>(
w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/normalize`),
{ method: 'POST', ...json({ merges }) },
),
updateColumn: (
table: string,
column: string,
input: {
/** What the column is called from now on; the identifier stays. */
label?: string;
options?: string[];
expr?: string;
format?: ColumnFormat | null;
required?: boolean;
sequence?: string | null;
},
) =>
request<{ column: ColumnMeta }>(
w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}`),
{ method: 'PATCH', ...json(input) },
),
dropColumn: (table: string, column: string) =>
request<{ ok: true }>(
w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}`),
{ method: 'DELETE' },
),
reorderColumns: (table: string, order: string[]) =>
request<{ table: unknown }>(w(`/tables/${encodeURIComponent(table)}/columns/reorder`), {
method: 'POST',
...json({ order }),
}),
setDisplayColumn: (table: string, display: string) =>
request<{ table: unknown }>(w(`/tables/${encodeURIComponent(table)}`), {
method: 'PATCH',
...json({ display }),
}),
validateFormula: (table: string, expr: string) =>
request<{ ok: boolean; error: string | null }>(w('/formula/validate'), {
method: 'POST',
...json({ table, expr }),
}),
createView: (
table: string,
input: {
name: string;
filters: Filter[];
sort: Sort | null;
summaries?: Summaries;
hidden?: string[];
},
) =>
request<{ view: ViewDef }>(w(`/tables/${encodeURIComponent(table)}/views`), {
method: 'POST',
...json(input),
}),
updateView: (
id: string,
input: {
name?: string;
filters?: Filter[];
sort?: Sort | null;
summaries?: Summaries;
hidden?: string[];
},
) =>
request<{ view: ViewDef }>(w(`/views/${encodeURIComponent(id)}`), {
method: 'PATCH',
...json(input),
}),
deleteView: (id: string) =>
request<{ ok: true }>(w(`/views/${encodeURIComponent(id)}`), { method: 'DELETE' }),
setRuleEnabled: (id: string, enabled: boolean) =>
request<{ rule: Rule }>(w(`/rules/${encodeURIComponent(id)}`), {
method: 'PATCH',
...json({ enabled }),
}),
audit: (query: { table?: string; rowId?: string; before?: number; limit?: number }) => {
const params = new URLSearchParams();
if (query.table) params.set('table', query.table);
if (query.rowId) params.set('rowId', query.rowId);
if (query.before) params.set('before', String(query.before));
params.set('limit', String(query.limit ?? 60));
return request<{ entries: AuditEntry[]; hasMore: boolean }>(w(`/audit?${params}`));
},
};
export type Api = typeof api;