Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { z } from 'zod';
import {
AGG_FNS,
COLUMN_TYPES,
IMPORT_COLUMN_TYPES,
PLAN_IDS,
FILTER_OPS,
SUPPORTED_LOCALES,
SEQUENCE_MAX_LENGTH,
STORED_COLUMN_TYPES,
isCurrencyCode,
isValidColumnName,
isValidTableName,
isValidWorkspaceSlug,
} from '@wellformed/shared';
/**
* How big an import may be, in the three places the number has to agree.
*
* A workbook is far denser than the CSV it would have been exported to — the
* XML compresses, and a year of a real ledger arrives in a few hundred
* kilobytes — so the file allowance is generous without being an invitation.
* The body limit sits above both because JSON escaping and base64 both add to
* what is actually sent.
*/
export const IMPORT_TEXT_BYTES = 4 * 1024 * 1024;
export const IMPORT_FILE_BASE64 = 8 * 1024 * 1024;
export const IMPORT_BODY_LIMIT = 12 * 1024 * 1024;
export const tableNameSchema = z.string().refine(isValidTableName, {
message: 'Invalid table name.',
});
export const columnNameSchema = z.string().refine(isValidColumnName, {
message: 'Invalid column name.',
});
export const rowIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'Invalid row id.');
export const filterSchema = z.object({
col: columnNameSchema,
op: z.enum(FILTER_OPS),
val: z.string().max(500).default(''),
});
export const sortSchema = z.object({
col: columnNameSchema,
dir: z.enum(['asc', 'desc']),
});
export const formatSchema = z.enum(['currency', 'number', 'percent', 'date', 'text']).nullable();
/** The aggregate row: at most one function per column, and no more than a table's worth. */
export const summariesSchema = z
.record(columnNameSchema, z.enum(AGG_FNS))
.refine((s) => Object.keys(s).length <= 100, 'Too many summarised columns.');
export const rowsQuerySchema = z.object({
view: z.string().max(64).optional(),
/** JSON-encoded `Filter[]`; the grid builds these client-side. */
filters: z.string().max(4000).optional(),
sort: z.string().max(200).optional(),
/** JSON-encoded `string[]`: columns to leave out of the SELECT, overriding the view's. */
hidden: z.string().max(4000).optional(),
q: z.string().max(200).optional(),
limit: z.coerce.number().int().min(1).max(1000).default(200),
offset: z.coerce.number().int().min(0).max(1_000_000).default(0),
});
/**
* The aggregate row asks over the same rows as the grid, so it takes the same
* view, filters and search — and no page, because it summarises all of them.
* Which functions to compute travels with the request rather than being read
* off the view, so the footer answers while you are still choosing.
*/
export const summaryQuerySchema = z.object({
view: z.string().max(64).optional(),
filters: z.string().max(4000).optional(),
/** JSON-encoded `string[]`, so a search scopes to the columns the grid shows. */
hidden: z.string().max(4000).optional(),
q: z.string().max(200).optional(),
/** JSON-encoded `Summaries`. */
summaries: z.string().max(4000).optional(),
});
/** What a column is called, in whoever's words. Identifiers are derived from it. */
const columnLabelSchema = z.string().trim().min(1).max(60);
export const createTableSchema = z.object({
/** Omitted, the server derives one from the label and steps past collisions. */
name: tableNameSchema.optional(),
label: z.string().min(1).max(60),
/** What to call the row-label column; its identifier is derived. */
displayLabel: columnLabelSchema.optional(),
/** The row-label column's identifier, when the API insists on one. */
display: columnNameSchema.optional(),
/** The sidebar heading to list the new table under. */
group: z.string().trim().min(1).max(60).optional(),
});
export const addColumnSchema = z
.object({
/** Omitted, the label stands in — the server derives the identifier. */
name: columnNameSchema.optional(),
label: columnLabelSchema.optional(),
type: z.enum(COLUMN_TYPES),
options: z.array(z.string().max(80)).max(100).optional(),
target: tableNameSchema.optional(),
expr: z.string().max(2000).optional(),
format: formatSchema.optional(),
required: z.boolean().optional(),
unique: z.boolean().optional(),
/** Numbering pattern; the engine validates its shape, this only its size. */
sequence: z.string().max(SEQUENCE_MAX_LENGTH).nullable().optional(),
})
.strict()
.refine((body) => body.name !== undefined || body.label !== undefined, {
message: 'A column needs a name.',
});
/**
* A tightening: which constraints to put on a column that already holds data.
*
* Every field is optional and absent means "leave it as it is", so the same
* body describes adding one constraint and changing four. `reading` is how the
* text now in the column should be understood while it converts, and exists
* because no value can answer either of its questions about itself.
*/
export const tightenSchema = z
.object({
type: z.enum(STORED_COLUMN_TYPES).optional(),
options: z.array(z.string().max(80)).max(100).optional(),
target: tableNameSchema.optional(),
required: z.boolean().optional(),
unique: z.boolean().optional(),
reading: z
.object({
decimal: z.enum(['.', ',']).optional(),
dayFirst: z.boolean().optional(),
})
.strict()
.optional(),
onProblem: z.enum(['stop', 'clear']).optional(),
})
.strict();
/**
* Normalising: which spellings merge into which. `from` values are matched
* exactly, whitespace and all — they came out of the plan, not out of a head.
*/
export const normalizeSchema = z
.object({
merges: z
.array(
z
.object({
to: z.string().min(1).max(1000),
from: z.array(z.string().min(1).max(1000)).min(1).max(100),
})
.strict(),
)
.min(1)
.max(100),
})
.strict();
export const updateColumnSchema = z
.object({
label: columnLabelSchema.optional(),
options: z.array(z.string().max(80)).max(100).optional(),
expr: z.string().max(2000).optional(),
format: formatSchema.optional(),
required: z.boolean().optional(),
position: z.number().int().min(0).max(500).optional(),
rename: columnNameSchema.optional(),
sequence: z.string().max(SEQUENCE_MAX_LENGTH).nullable().optional(),
})
.strict();
export const reorderColumnsSchema = z.object({
order: z.array(columnNameSchema).min(1).max(500),
});
export const reorderTablesSchema = z.object({
order: z.array(tableNameSchema).min(1).max(500),
});
/** Columns a view leaves out. Capped like the column count itself. */
export const hiddenSchema = z.array(columnNameSchema).max(500);
export const upsertViewSchema = z.object({
name: z.string().min(1).max(60),
filters: z.array(filterSchema).max(20).default([]),
sort: sortSchema.nullable().default(null),
summaries: summariesSchema.default({}),
hidden: hiddenSchema.default([]),
});
/**
* Changing part of a saved view.
*
* Spelled out rather than `upsertViewSchema.partial()`, because `.partial()`
* keeps the defaults above: a body of `{ name }` would come back out as
* `{ name, filters: [], sort: null }` and quietly strip the view of everything
* that made it a view. Here a field that was not sent stays `undefined`, and
* `sort: null` keeps its own meaning — explicitly unsorted.
*/
export const patchViewSchema = z
.object({
name: z.string().min(1).max(60).optional(),
filters: z.array(filterSchema).max(20).optional(),
sort: sortSchema.nullable().optional(),
summaries: summariesSchema.optional(),
hidden: hiddenSchema.optional(),
})
.strict();
export const ruleSchema = z.object({
table: tableNameSchema,
column: columnNameSchema,
equals: z.string().max(200),
label: z.string().min(1).max(120),
enabled: z.boolean().default(true),
actions: z
.array(
z.object({
type: z.literal('set'),
column: columnNameSchema,
value: z.string().max(200),
}),
)
.min(1)
.max(10),
});
export const loginSchema = z.object({
email: z.string().email().max(200),
password: z.string().min(1).max(200),
});
export const roleSchema = z.enum(['admin', 'editor', 'viewer']);
export const slugSchema = z.string().refine(isValidWorkspaceSlug, {
message: 'Invalid workspace address.',
});
/**
* What to build into a new workspace: nothing, the demo's structure, or the
* demo with its data. Omitted means empty — most people arrive with the
* spreadsheet they want to replace, and the first screen inside offers it.
*/
export const demoChoiceSchema = z.enum(['none', 'structure', 'full']).optional();
/**
* A signup makes an account and nothing else. The workspace comes after, from
* the picker — creating both in one breath meant a person choosing a workspace
* name before they had ever seen one.
*/
export const signupSchema = z
.object({
email: z.string().email().max(200),
name: z.string().min(1).max(80),
password: z.string().min(1).max(200),
/**
* The landing page this signup came from, quoted back from the browser.
*
* Optional in every sense: someone arriving by invite link, by a bookmark,
* or with storage disabled has no impression to name, and a signup must
* never depend on the measurement working.
*/
voice: z.string().max(64).optional(),
})
.strict();
/** The impression id in `/api/public/voice/:id/seen`. Shaped like every other id. */
export const voiceIdParamSchema = z.object({ id: z.string().regex(/^[a-z0-9_]{1,64}$/) }).strict();
export const createWorkspaceSchema = z
.object({
name: z.string().min(1).max(80),
slug: slugSchema.optional(),
demo: demoChoiceSchema,
/**
* Chosen on the creation screen; the Accept-Language header only answers
* when the form did not ask. A workspace's language decides how its
* numbers and dates are written, so it must be a choice the person can
* see, not a guess they discover after their first import.
*/
locale: z.enum(SUPPORTED_LOCALES).optional(),
})
.strict();
/**
* Any ISO 4217 code, not only the ones the dialog lists. The list is a
* convenience; refusing a currency because it did not make a dropdown would be
* this product telling a business what money it earns.
*/
export const currencySchema = z
.string()
.refine(isCurrencyCode, { message: 'Use a three-letter currency code, such as EUR.' })
.transform((code) => code.toUpperCase());
export const updateWorkspaceSchema = z
.object({
name: z.string().min(1).max(80).optional(),
slug: slugSchema.optional(),
currency: currencySchema.optional(),
locale: z.enum(SUPPORTED_LOCALES).optional(),
})
.strict();
export const createInviteSchema = z
.object({
email: z.string().email().max(200),
role: roleSchema,
})
.strict();
export const acceptInviteSchema = z
.object({
/** Both required when the invite's email has no account yet. */
name: z.string().min(1).max(80).optional(),
password: z.string().min(1).max(200).optional(),
})
.strict();
export const updateMemberSchema = z
.object({
role: roleSchema.optional(),
})
.strict();
export const updateAccountSchema = z
.object({
name: z.string().min(1).max(80),
})
.strict();
export const changePasswordSchema = z.object({
current: z.string().min(1).max(200),
next: z.string().min(1).max(200),
});
export const checkoutSchema = z.object({
plan: z.enum(PLAN_IDS),
/**
* Where the invoices should go. Optional only so that a workspace which has
* already said keeps its answer; the route refuses when neither is present,
* rather than falling back to whoever is clicking.
*/
billingEmail: z.string().email('That is not an email address.').max(200).optional(),
});
export const billingContactSchema = z.object({
billingEmail: z.string().email('That is not an email address.').max(200),
});
/**
* Four megabytes of text, which at ~40 bytes a row is comfortably more than
* the row cap the importer applies anyway. The import routes raise their own
* body limit past the server's default to leave room for it.
*/
const csvSchema = z.string().min(1).max(IMPORT_TEXT_BYTES);
/**
* A file, as base64.
*
* A workbook is not text and cannot be pasted into a JSON string, and the rest
* of this API is JSON. Base64 costs a third more bytes on the wire and buys
* one transport for every request rather than a multipart exception for one of
* them.
*/
const uploadSchema = z
.object({
name: z.string().min(1).max(200),
data: z.string().min(1).max(IMPORT_FILE_BASE64),
})
.strict();
/** Which row names the columns; omitted means the parser works it out. */
const headerRowSchema = z.number().int().min(0).max(200_000).optional();
/**
* Which row groups the columns. Omitted lets the parser decide; null says no
* row does, whatever it thinks; a number names the row.
*/
const groupRowSchema = z.number().int().min(0).max(200_000).nullable().optional();
/** Which sheet of a workbook to read; omitted means the first with anything in it. */
const sheetSchema = z.string().min(1).max(200).optional();
/**
* Corrections to the preview's guessed columns, addressed by position. A
* correction is a label — any words at all — and the importer derives the
* identifier from it the way the column dialog would.
*/
const importColumnsSchema = z
.array(
z
.object({
index: z.number().int().min(0).max(999),
label: z.string().min(1).max(60).optional(),
type: z.enum(IMPORT_COLUMN_TYPES).optional(),
})
.strict(),
)
.max(60)
.optional();
/** Text or a file, and exactly one of them — two sources would be two answers. */
/**
* Corrections to the group headings, by position in the preview's `groups`.
* An empty label is legal: it takes the heading off its whole group.
*/
const importGroupsSchema = z
.array(
z
.object({
index: z.number().int().min(0).max(999),
label: z.string().max(60),
})
.strict(),
)
.max(60)
.optional();
const importSource = {
csv: csvSchema.optional(),
file: uploadSchema.optional(),
headerRow: headerRowSchema,
groupRow: groupRowSchema,
/** Trailing totals rows to leave out; 0 keeps every row. Omitted, the parser decides. */
footerRows: z.number().int().min(0).max(1000).optional(),
sheet: sheetSchema,
columns: importColumnsSchema,
groups: importGroupsSchema,
};
const oneSource = (body: { csv?: string; file?: unknown }) =>
Boolean(body.csv) !== Boolean(body.file);
export const previewImportSchema = z
.object({
...importSource,
/** Preview against this table's columns, when appending into one. */
table: tableNameSchema.optional(),
})
.strict()
.refine(oneSource, 'Send either pasted text or a file, not both.');
export const importSchema = z
.object({
...importSource,
/** Append into this table, or omit to create one. */
table: tableNameSchema.optional(),
/** Only when creating: the new table's name and label. */
name: tableNameSchema.optional(),
label: z.string().min(1).max(60).optional(),
})
.strict()
.refine(oneSource, 'Send either pasted text or a file, not both.');
export const forgotSchema = z.object({
email: z.string().email().max(200),
});
export const resetPasswordSchema = z.object({
password: z.string().min(1).max(200),
});
/** Reset and confirmation links carry a 32-byte base64url secret in the path. */
export const tokenParamSchema = z.object({
token: z.string().min(20).max(200),
});
export const auditQuerySchema = z.object({
table: tableNameSchema.optional(),
rowId: rowIdSchema.optional(),
before: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().min(1).max(500).default(60),
});
export const validateExprSchema = z.object({
table: tableNameSchema,
expr: z.string().min(1).max(2000),
});