--- name: constructive-meta-forms description: Use the _meta GraphQL endpoint on any Constructive app-public DB to introspect table schema at runtime and render fully dynamic CRUD forms with zero static field configuration. Covers DynamicFormCard (create/edit/delete), locked FK pre-fill from context (defaultValues + defaultValueLabels), and the O2M/M2M related-record pattern. Use when building create/edit/delete UI for any Constructive-provisioned table. compatibility: Next.js 14+ (App Router), Constructive SDK, @tanstack/react-query, graphql-request metadata: author: constructive-io version: "2.0.0" --- # Constructive `_meta` Dynamic Forms Build fully dynamic CRUD forms for **any** Constructive-provisioned table — zero static field configuration required. The `_meta` query built into every Constructive `app-public` GraphQL endpoint tells you field names, types, required status, FK relationships, and mutation names — all at runtime. One component. Any table. No codegen needed for forms. --- ## 1. What `_meta` gives you ```graphql query GetMeta { _meta { tables { name fields { name isNotNull hasDefault type { pgType gqlType isArray } } inflection { tableType createInputType patchType filterType orderByType } query { all one create update delete } primaryKeyConstraints { name fields { name } } foreignKeyConstraints { name fields { name } referencedTable referencedFields } uniqueConstraints { name fields { name } } } } } ``` - `fields` → names, types, nullability, defaults — enough to render any input - `inflection` → exact GraphQL type names for mutations (`CreateContactInput`, `ContactPatch`) - `query` → exact mutation/query resolver names (`createContact`, `updateContact`, `deleteContact`) - `foreignKeyConstraints` → which fields are FKs and what table they reference - **Fetch once with `staleTime: Infinity`** — schema never changes at runtime --- ## 2. TypeScript types ```ts // src/types/meta.ts export type MetaField = { name: string; isNotNull: boolean; hasDefault: boolean; type: { pgType: string; gqlType: string; isArray: boolean }; }; export type MetaTable = { name: string; fields: MetaField[]; inflection: { tableType: string; createInputType: string; patchType: string | null; filterType: string | null; orderByType: string; }; query: { all: string; // e.g. "contacts" one: string | null; // ⚠️ may be a non-existent root field — see §3 bug note create: string | null; update: string | null; delete: string | null; }; primaryKeyConstraints: Array<{ name: string; fields: { name: string }[] }>; foreignKeyConstraints: Array<{ name: string; fields: { name: string }[]; referencedTable: string; referencedFields: string[]; }>; uniqueConstraints: Array<{ name: string; fields: { name: string }[] }>; }; ``` --- ## 3. ⚠️ Platform bug: `query.one` returns a non-existent root field `_meta.query.one` returns the **singular** name (e.g. `"contact"`) but the Constructive GraphQL root only exposes **plural** queries (e.g. `contacts`). Using `query.one` as the root field will fail. **Fix — always use `query.all` + `condition: { id: $id }`:** ```ts function buildFetchQuery(table: MetaTable): string { const fieldNames = table.fields.map((f) => f.name).join('\n '); // Use query.all with a condition filter + read nodes[0] // DO NOT use query.one — it returns a non-existent root field name return ` query DynamicFetch($id: UUID!) { ${table.query.all}(condition: { id: $id }) { nodes { ${fieldNames} } } } `; } // Read the result: const result = data[table.query.all].nodes[0] as Record | undefined; ``` --- ## 4. `useMeta` / `useTableMeta` hooks ```ts // src/lib/meta/use-meta.ts 'use client'; import { useQuery } from '@tanstack/react-query'; import { CRM_ENDPOINT } from '@/components/crm/crm-provider'; import { TokenManager } from '@/lib/auth/token-manager'; import type { MetaTable } from '@/types/meta'; const META_QUERY = `query GetMeta { _meta { tables { name fields { name isNotNull hasDefault type { pgType gqlType isArray } } inflection { tableType createInputType patchType filterType orderByType } query { all one create update delete } primaryKeyConstraints { name fields { name } } foreignKeyConstraints { name fields { name } referencedTable referencedFields } uniqueConstraints { name fields { name } } } } }`; async function fetchMeta(): Promise<{ _meta: { tables: MetaTable[] } }> { const { token } = TokenManager.getToken('schema-builder'); const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json', }; if (token) headers['Authorization'] = `Bearer ${token.accessToken}`; const res = await fetch(CRM_ENDPOINT, { method: 'POST', headers, body: JSON.stringify({ query: META_QUERY }), }); if (!res.ok) throw new Error(`_meta fetch failed: ${res.status}`); const json = await res.json(); if (json.errors?.length) throw new Error(json.errors[0].message ?? '_meta error'); return json.data; } export function useMeta() { return useQuery({ queryKey: ['_meta'], queryFn: fetchMeta, staleTime: Infinity }); } export function useTableMeta(tableName: string): MetaTable | null { const { data } = useMeta(); return data?._meta.tables.find((t) => t.name === tableName) ?? null; } ``` --- ## 5. Field renderer utilities ```ts // src/lib/meta/field-renderer.ts import type { MetaField } from '@/types/meta'; /** System fields — always skip in forms (auto-managed by Constructive) */ export const SYSTEM_FIELDS = new Set([ 'id', 'entityId', 'createdAt', 'updatedAt', 'created_at', 'updated_at', 'entity_id', ]); export type FieldInputType = | 'text' | 'textarea' | 'number' | 'boolean' | 'date' | 'datetime' | 'uuid' | 'json' | 'select' | 'hidden'; const TEXTAREA_HINTS = ['bio', 'description', 'notes', 'body', 'content', 'summary', 'details']; export function getInputType(field: MetaField, isForeignKey: boolean): FieldInputType { if (SYSTEM_FIELDS.has(field.name)) return 'hidden'; if (isForeignKey) return 'select'; const pg = field.type.pgType.toLowerCase(); switch (pg) { case 'text': case 'varchar': case 'citext': return TEXTAREA_HINTS.some((h) => field.name.toLowerCase().includes(h)) ? 'textarea' : 'text'; case 'int2': case 'int4': case 'int8': case 'float4': case 'float8': case 'numeric': return 'number'; case 'bool': case 'boolean': return 'boolean'; case 'date': return 'date'; case 'timestamp': case 'timestamptz': return 'datetime'; case 'uuid': return 'uuid'; case 'json': case 'jsonb': return 'json'; default: return 'text'; } } /** * A field is required if it's NOT NULL AND has no server-side default. * hasDefault=true = Constructive auto-generates the value (ids, timestamps, etc.) — never require in forms. */ export function isRequiredField(field: MetaField): boolean { return field.isNotNull && !field.hasDefault; } /** camelCase → "Title Case" label */ export function toLabel(fieldName: string): string { return fieldName.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase()).trim(); } ``` ### Required field rule | `isNotNull` | `hasDefault` | In form | |---|---|---| | `true` | `false` | Required input | | `true` | `true` | Skip in create (id, timestamps), optional in edit | | `false` | anything | Optional input | --- ## 6. `DynamicField` component Handles all pgTypes automatically. Add `locked` + `lockedLabel` for pre-filled FK context (see §8). ```tsx // src/components/crm/dynamic-field.tsx 'use client'; import { Field } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { getInputType, SYSTEM_FIELDS, toLabel } from '@/lib/meta/field-renderer'; import type { MetaField } from '@/types/meta'; import { Lock } from 'lucide-react'; type DynamicFieldProps = { field: MetaField; value: unknown; onChange: (value: unknown) => void; isForeignKey?: boolean; /** Pre-set from context — visible but not editable */ locked?: boolean; /** Human-readable label for locked field (e.g. "Kristopher Floyd" instead of a UUID) */ lockedLabel?: string; error?: string; }; export function DynamicField({ field, value, onChange, isForeignKey = false, locked = false, lockedLabel, error, }: DynamicFieldProps) { if (SYSTEM_FIELDS.has(field.name)) return null; const inputType = getInputType(field, isForeignKey); const label = toLabel(field.name); const required = field.isNotNull && !field.hasDefault; // ── Locked: visible, disabled, not editable ── if (locked) { const displayValue = lockedLabel ?? (typeof value === 'string' ? value : String(value ?? '')); return (
{lockedLabel && (

{String(value)}

)}
); } if (inputType === 'hidden') return null; if (inputType === 'boolean') { return (
{error &&

{error}

}
); } if (inputType === 'textarea') { return (