Server-side drafts for the request-form builder (PMS-759) #514
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-759-server-side-form-drafts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Server half of PMS-759. The SPA half is psa-systems/mokosh-apps (linked below once open).
PMS-754 and PMS-756 already shipped autosave, restore and a discard confirmation for the request-form builder, all of it in
localStorage. Vas asked for storage that survives a different machine, andlocalStorageis per-origin, per-profile, per-device: the demo works perfectly on one laptop and loses everything on the next one.What this adds
form_definition_drafts: an opaque JSONB snapshot of the editor, keyed by tenant, by the user who owns it, and by the definition being edited (null while the form is new). PlusGET,PUTandDELETE /api/v1/forms/drafts.A draft is not a form definition. The alternative, a
statuscolumn onform_definitions, was rejected: every existing query filtersis_active, the request-link issuer resolves a definition by id and would happily send a half-built one, andform_fieldscarries NOT NULL constraints a half-built form cannot satisfy. As its own record, "a draft cannot be sent to a client" needs no enforcement code at all.Two partial unique indexes, not one plain UNIQUE. NULLs are distinct in a unique index and
form_definition_idis NULL while a form is new, so a plain constraint would let one user accumulate an unbounded pile of new-form drafts, one per keystroke batch. Split this way each half is also a validON CONFLICTinference target, which is what makes the autosave an upsert rather than a read-then-write race.The payload stays opaque. It is the editor's own shape; a second copy of it in Rust is a thing to keep in step for no benefit, since the only thing read inside it is
name, for the drafts list. It is size-capped instead, and rejected rather than truncated: a truncated draft restores as a corrupted form.Ownership is a predicate, not an afterthought.
user_idis in the WHERE clause of every read and of the delete, so another user's draft is a 404 rather than a 403 and a caller cannot learn the id exists. Tenant isolation is the usual fail-closed RLS policy on top.Saving the form clears its draft server-side, on both create and update, rather than leaving it to the SPA. A draft exists to survive the browser going away, so it cannot depend on the browser to tidy up.
Verification
just checkclean, full Postgres integration suite green, seven new tests intests/form_drafts.rs: repeated autosaves leaving one row, new-form and edit drafts coexisting, cross-user invisibility on both read and delete, a cross-tenant definition id being refused, retirement on save, and the size cap.🤖 Generated with Claude Code
https://claude.ai/code/session_01X29MwLvt6mgSKrpqzh5zqB
`GET /forms/drafts` lists the caller's own, `PUT /forms/drafts` upserts one, `DELETE /forms/drafts/{id}` discards it. Admin-gated to match create and update: a draft is a half-written definition, so the people who can author one are the people who can hold one. The payload stays opaque. It is the editor's own shape, and a second copy of that shape in Rust would be a thing to keep in step for no benefit: the only thing read inside it is `name`, so the drafts list has a label. Being opaque and client-supplied is why it is size-capped instead, and rejected rather than truncated, because a truncated draft restores as a corrupted form. Ownership is a predicate on every read and on the delete, not a check applied afterwards, so another user's draft is a 404 rather than a 403: a caller should not learn that the id exists. The definition named by a draft is verified inside the tenant transaction for the same reason. Saving the form clears its draft server-side rather than leaving it to the SPA. A draft exists to survive the browser going away, so it cannot depend on the browser to tidy up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X29MwLvt6mgSKrpqzh5zqB