import { z } from "zod"; import { PROPERTY_TYPES, VIEW_TYPES, RELATION_CARDINALITIES, ROLLUP_AGGREGATES, MAX_CSV_IMPORT_BODY_BYTES } from "../../constants.js"; /** * Property type, case-tolerant on input — mirrors the REST block type * (`blocks/dto.ts` `blockTypeSchema`). * * The DB/REST wire enum is UPPERCASE (`PROPERTY_TYPES`), but the web * database layer is lowercase end-to-end (the `PropertyType` union, * `TYPE_META`, and every `p.type === "date"` comparison are lowercase), so * `POST /databases/:id/properties` from the editor sends `"date"`. Without * this transform the strict `z.enum` 400s and "Add property" never persists * (verified in the Phase-15 E2E: creating the "Due" DATE property). UPPERCASE * input is unchanged (backward compatible — seeded defaults and the * integration tests send and expect `TITLE`/`SELECT`/…). */ const propertyTypeSchema = z .string() .min(1) .transform((t) => t.toUpperCase()) .pipe(z.enum(PROPERTY_TYPES)); const SelectOptions = z .object({ name: z.string().min(1).max(50), color: z.string().max(20).optional() }) .array() .min(1) .max(100); const PropertyConfig = z .object({ options: SelectOptions.optional(), format: z.string().max(30).optional(), relationId: z.string().min(1).optional(), sourcePropertyId: z.string().min(1).optional(), aggregate: z.enum(ROLLUP_AGGREGATES).optional(), targetDatabaseId: z.string().min(1).optional(), button: z.record(z.unknown()).optional(), min: z.number().optional(), max: z.number().optional() }) .passthrough(); export const CreatePropertyDto = z.object({ name: z.string().min(1).max(100), type: propertyTypeSchema, config: PropertyConfig.default({}), required: z.boolean().optional(), isVisible: z.boolean().optional(), default: z.unknown().optional() }); export type CreatePropertyInput = z.infer; export const UpdatePropertyDto = z.object({ name: z.string().min(1).max(100).optional(), config: PropertyConfig.optional(), required: z.boolean().optional(), isVisible: z.boolean().optional(), default: z.unknown().optional() }); export type UpdatePropertyInput = z.infer; const ViewConfig = z .object({ group: z.object({ propertyId: z.string(), dir: z.enum(["asc", "desc"]).default("asc") }).optional(), sort: z.array(z.object({ propertyId: z.string(), dir: z.enum(["asc", "desc"]).default("asc") })).max(10).optional(), filter: z.record(z.unknown()).nullable().optional(), cardPropertyId: z.string().optional(), datePropertyId: z.string().optional(), startDatePropertyId: z.string().optional(), endDatePropertyId: z.string().optional(), chart: z.record(z.unknown()).optional(), buttons: z.array(z.string()).optional(), visibleProperties: z.array(z.string()).optional(), pageSize: z.number().int().min(10).max(500).optional() }) .passthrough(); export const CreateViewDto = z.object({ name: z.string().min(1).max(50), type: z.enum(VIEW_TYPES), config: ViewConfig.default({}) }); export type CreateViewInput = z.infer; export const UpdateViewDto = z.object({ name: z.string().min(1).max(50).optional(), config: ViewConfig.optional(), filter: z.record(z.unknown()).nullable().optional(), sort: z.array(z.object({ propertyId: z.string(), dir: z.enum(["asc", "desc"]).default("asc") })).max(10).nullable().optional(), group: z.object({ propertyId: z.string(), dir: z.enum(["asc", "desc"]).default("asc") }).nullable().optional(), // T-166: the view's stored rows-per-page (the §6 query already READS // `view.pageSize` as the listing default — this is the missing WRITE side). // Bounds match the `pageSize` on the view's own ViewConfig. pageSize: z.number().int().min(10).max(500).nullable().optional() }); export type UpdateViewInput = z.infer; export const UpdateDatabaseDto = z.object({ title: z.string().max(200).optional(), icon: z.string().max(20).nullable().optional(), description: z.string().max(500).nullable().optional() }); export type UpdateDatabaseInput = z.infer; export const CreateRelationDto = z.object({ name: z.string().min(1).max(100), targetDatabaseId: z.string().min(1), reverseName: z.string().min(1).max(100).optional(), twoWay: z.boolean().default(true), cardinality: z.enum(RELATION_CARDINALITIES).default("MANY_TO_MANY") }); export type CreateRelationInput = z.infer; export const CreateFormulaDto = z.object({ name: z.string().min(1).max(100), expression: z.string().min(1).max(2000), /** * TASK-0124: optional engine-validated AST + inferred return type. When the * caller already validated the expression with `packages/database-engine` * (the AI formula generator does — the spec requires engine validation * BEFORE saving), these are stored verbatim instead of the `{raw}` * placeholder. Omitted → the historical placeholder (backward compatible). */ ast: z.unknown().optional(), returnType: z.string().max(50).optional() }); export type CreateFormulaInput = z.infer; export const ValidateFormulaDto = z.object({ expression: z.string().min(1).max(2000), sampleRecordIds: z.array(z.string().max(50)).max(100).optional() }); export type ValidateFormulaInput = z.infer; export const CreateRecordDto = z.object({ title: z.string().max(200).optional(), /** propertyId → value */ properties: z.record(z.unknown()).default({}), /** relationPropertyId → target record ids */ links: z.record(z.array(z.string()).default([])).default({}) }); export type CreateRecordInput = z.infer; export const UpdateRecordDto = z.object({ title: z.string().max(200).optional(), properties: z.record(z.unknown()).optional(), icon: z.string().max(20).nullable().optional(), coverUrl: z.string().url().max(2048).nullable().optional(), archived: z.boolean().optional(), links: z.record(z.array(z.string())).optional() }); export type UpdateRecordInput = z.infer; export const BulkRecordsDto = z.object({ op: z.enum(["update", "delete"]), recordIds: z.array(z.string().min(1)).min(1).max(500), properties: z.record(z.unknown()).optional() }); export type BulkRecordsInput = z.infer; export const ListRecordsQuery = z.object({ viewId: z.string().min(1).optional(), filter: z.string().max(8192).optional(), sort: z.string().max(2048).optional(), group: z.string().max(2048).optional(), cursor: z.string().optional(), limit: z.string().optional(), include: z.enum(["none", "relations"]).optional() }); export type ListRecordsInput = z.infer; /** * TASK-0124 (repair): the docs §6 query DSL body for * `POST /databases/:id/query` — documented in docs/api/databases.md §6 since * Phase 6 but never implemented, which left the web table view (and every * other `useDatabaseRecords` consumer) receiving 404 for the records fetch. * camelCase, JSON bodies (the client never sends strings here — unlike the * `GET /records` query-string variant `ListRecordsQuery`). `viewId` applies * the view's stored filter/sort/group/pageSize as defaults; explicit body * fields override (same precedence as `list()`). * * `aggregate` is accepted (docs §6) but currently returns no `aggregates` * object (recorded conflict: no client sends it; the flat/grouped response * is what `useDatabaseRecords` consumes). */ export const DatabaseQueryDto = z.object({ viewId: z.string().min(1).optional(), select: z.array(z.string().min(1)).max(64).optional(), filter: z.unknown().optional(), sort: z.array(z.object({ propertyId: z.string().min(1), direction: z.enum(["asc", "desc"]).default("asc") })).max(16).optional(), group: z.array(z.object({ propertyId: z.string().min(1), direction: z.enum(["asc", "desc"]).optional() })).max(16).optional(), aggregate: z .array( z.object({ propertyId: z.string().min(1), fn: z.enum(["COUNT", "SUM", "AVERAGE", "MIN", "MAX", "UNIQUE"]), perGroup: z.boolean().optional() }) ) .max(8) .optional(), includeArchived: z.boolean().default(false), cursor: z.string().nullable().optional(), pageSize: z.number().int().min(1).max(200).default(50) }); export type DatabaseQueryInput = z.infer; export const ListFormulasQuery = z.object({}); // ---------- CSV import (T-165) ---------- /** One CSV column as reported by a dry-run mapping. */ export const CsvImportColumn = z.object({ /** 0-based column index in the CSV. */ index: z.number().int().min(0), /** The header cell text ("" for a ragged/short header row). */ header: z.string(), /** Matched writable property id, or null when unmapped. */ propertyId: z.string().nullable(), /** Human-readable reason when unmapped ("" when mapped). */ reason: z.string().default("") }); export type CsvImportColumnInput = z.infer; /** * `POST /databases/:id/import/csv` body. * * `dryRun: true` (the default) returns the column mapping + a sample and * writes NOTHING. The client then re-POSTs with `dryRun: false` (optionally * after correcting the auto-mapping via `mappings`) to actually import. */ export const CsvImportDto = z.object({ /** Raw CSV text (header row + data rows). Parsed as RFC 4180. */ csv: z.string().min(1).max(MAX_CSV_IMPORT_BODY_BYTES), /** When true, only the mapping is computed — nothing is written. */ dryRun: z.boolean().default(true), /** * Explicit header → propertyId overrides. A header whose name-based match * was wrong (or a ragged column with no header) can be pointed at a * specific writable property. Values are property ids. */ mappings: z.record(z.string().min(1)).optional(), /** Header name of the title column (defaults to the title property's name). */ titleHeader: z.string().optional() }); export type CsvImportInput = z.infer;