Problem
schema-forge site generate doesn't have a code branch for file field types in edit.generated.tsx.jinja. Schemas with a file field fall through to the default <input> branch in the form generator and emit:
<input
id={field.name}
{...field}
value={field.value as string | undefined ?? ""}
className={"input" + (fieldState.error ? " invalid" : "")}
/>
field.value for a file-typed field is a FileAttachment object ({ key, status, size, mime, uploaded_at?, checksum? }), not a string. TypeScript catches the cast at build time:
src/app/pages/<entity>/edit.generated.tsx(140,24): error TS2352: Conversion of
type '{ key: string; status: string; size: number; mime: string; ... }' to type
'string' may be a mistake because neither type sufficiently overlaps with the
other.
Even if you bypass the cast, the runtime renders [object Object] in the input — the generated form is non-functional for file fields. detail.generated.tsx.jinja and list.generated.tsx.jinja have no file handling at all (same hole).
Reproduction
Any schema with a file field, e.g.:
schema Document {
name: text(max: 255) required
attachment: file(bucket: "documents", max_size: "10MB", mime: ["application/pdf"]) required
}
schema-forge site generate -s schemas -o site
cd site && pnpm install && pnpm build
# fails on edit.generated.tsx
What the file branch needs to do
The runtime already exposes a three-endpoint upload flow:
POST /api/v1/forge/schemas/<schema>/entities/<id>/fields/<field>/upload-url — mint presigned PUT URL
PUT <presigned_url> — client uploads bytes directly to S3
POST /api/v1/forge/schemas/<schema>/entities/<id>/fields/<field>/confirm-upload — register the upload
The codegen should emit a <FileUpload> component that:
- In edit mode with an existing attachment: shows filename / status / size and offers a "Replace" button that runs the three-step flow against the existing entity id
- In create mode (no entity id yet): defers the upload until after the entity is created — typical pattern is "save first, then upload" with a two-phase form, OR creates a draft entity, OR (simplest) renders a disabled state with copy explaining file uploads happen after first save
- Surfaces the file-state-machine status (
pending → uploaded → scanning → available|quarantined|rejected)
- Uses the field's
access mode (presigned vs proxied) for download links
- Vendors as
src/components/ui/file-upload.tsx alongside the other shadcn primitives (or as a SchemaForge-specific component if it ends up too coupled to the upload flow)
detail.generated.tsx.jinja likely has the same kind of mismatch when displaying file attachments. list.generated.tsx.jinja should never include a file column by default — it's not summarisable in a list cell, and a column would force N extra presigned-URL fetches per page.
Open design questions (resolve before implementation)
- Create-mode strategy: two-phase form ("save first, then upload"), draft entity, or disabled-with-copy. Two-phase is simplest and matches typical shadcn/Hook Form stacks; draft entities introduce GC complexity.
- Component location: vendored
src/components/ui/file-upload.tsx (shadcn convention) or SchemaForge-specific path? It's not a generic primitive — it knows about the 3-endpoint flow and the file-state-machine.
- Polling for
scanning → available: server-sent event, client polling, or user-triggered refresh? Avirum scan latency varies; need a UX answer before coding.
Workaround applied locally (stub, do not ship)
crates/schema-forge-cli/templates/site/src/app/pages/edit.generated.tsx.jinja has a read-only stub branch for f.kind == "file" that unblocks pnpm build but leaves users without an upload UI:
{%- elif f.kind == "file" %}
<div className="mono" style={ { ... }}>
{(() => {
const att = field.value as { key?: string; status?: string; size?: number } | null | undefined
return att && att.key
? `${att.key} · ${att.status ?? "unknown"}${att.size ? ` · ${att.size} bytes` : ""}`
: "no file attached — upload via API"
})()}
</div>
Uploads have to be made via direct API calls until a real <FileUpload> widget ships.
Acceptance criteria
Split-out adjacent issues
Two smaller things spotted while reproducing this — split into their own issues:
Problem
schema-forge site generatedoesn't have a code branch forfilefield types inedit.generated.tsx.jinja. Schemas with afilefield fall through to the default<input>branch in the form generator and emit:field.valuefor afile-typed field is aFileAttachmentobject ({ key, status, size, mime, uploaded_at?, checksum? }), not a string. TypeScript catches the cast at build time:Even if you bypass the cast, the runtime renders
[object Object]in the input — the generated form is non-functional for file fields.detail.generated.tsx.jinjaandlist.generated.tsx.jinjahave nofilehandling at all (same hole).Reproduction
Any schema with a
filefield, e.g.:What the file branch needs to do
The runtime already exposes a three-endpoint upload flow:
POST /api/v1/forge/schemas/<schema>/entities/<id>/fields/<field>/upload-url— mint presigned PUT URLPUT <presigned_url>— client uploads bytes directly to S3POST /api/v1/forge/schemas/<schema>/entities/<id>/fields/<field>/confirm-upload— register the uploadThe codegen should emit a
<FileUpload>component that:pending → uploaded → scanning → available|quarantined|rejected)accessmode (presignedvsproxied) for download linkssrc/components/ui/file-upload.tsxalongside the other shadcn primitives (or as a SchemaForge-specific component if it ends up too coupled to the upload flow)detail.generated.tsx.jinjalikely has the same kind of mismatch when displaying file attachments.list.generated.tsx.jinjashould never include a file column by default — it's not summarisable in a list cell, and a column would force N extra presigned-URL fetches per page.Open design questions (resolve before implementation)
src/components/ui/file-upload.tsx(shadcn convention) or SchemaForge-specific path? It's not a generic primitive — it knows about the 3-endpoint flow and the file-state-machine.scanning → available: server-sent event, client polling, or user-triggered refresh? Avirum scan latency varies; need a UX answer before coding.Workaround applied locally (stub, do not ship)
crates/schema-forge-cli/templates/site/src/app/pages/edit.generated.tsx.jinjahas a read-only stub branch forf.kind == "file"that unblockspnpm buildbut leaves users without an upload UI:Uploads have to be made via direct API calls until a real
<FileUpload>widget ships.Acceptance criteria
edit.generated.tsx.jinjahas anf.kind == "file"branch that renders a real upload widget driving the three-endpoint flowdetail.generated.tsx.jinjarenders attachments with at least filename / size / state / download linklist.generated.tsx.jinjaeither hides file fields by default or renders a non-crashing placeholder<FileUpload>component ships alongside the existing shadcn primitivespnpm buildpasses on a generated site for any schema usingfilefields, with no TS escape hatches needed in either generated or preserve filesscanning→quarantinedfailures)Split-out adjacent issues
Two smaller things spotted while reproducing this — split into their own issues:
principal-claims-reference.mddocuments a TOML form forsourcethat doesn't deserialize/healthreports a stale version (lags the workspace crate version)