# Formsets

Core FORM-03 battery for bounded collections of model forms and atomic inline
related-object editing. PostgreSQL 18 and GORM are supported. This is a separate
Go module, using experimental Cicadel app API 1 and the new `forms/model` API.
The declared app version is `v0.1.0`; no published module release is claimed.

For local development, require `github.com/fauzaanu/cicadel/contrib/formsets v0.0.0`
and replace it with this directory; also replace the framework module with the
repository root. Go does not inherit a dependency's replace directives. Consumers
must pin a real framework/module release once published. The generated consumer
test verifies local source integration, not remote versioned installation.

Standard projects register `formsets.App()` in `project/apps.go` for inventory
and provider validation. Retain this entry when adding local apps. The package owns no routes, schemas, assets, workers or migrations;
removing it never deletes application data. The default project includes this module through the public installable contract.

## Define and submit a collection

```go
set, err := formsets.New[LineItem](db, formsets.Config[LineItem]{
    Model: model.Config[LineItem]{Fields: []model.Field{
        {Name: "Description", Required: true, MaxRunes: 200},
        {Name: "Quantity", Required: true},
    }},
    Prefix: "lines", MaxForms: 100, MinItems: 1, MaxItems: 50,
    AllowDelete: true,
})
// Handle configuration errors during setup.
collection := formsets.Inline[Order, LineItem](orderID, "OrderID",
    func(tx *gorm.DB) *gorm.DB { return tx.Where("owner_id = ?", userID) })
posted, err := forms.Parse(r)
// Handle parse failure; use body limits, authentication and CSRF middleware.
bound := set.Bind(posted.Values)
result, err := bound.Save(r.Context(), collection)
```

Use imports `github.com/fauzaanu/cicadel/contrib/formsets`,
`github.com/fauzaanu/cicadel/forms`, and `model
"github.com/fauzaanu/cicadel/forms/model"`. Define parent/child models and recorded
PostgreSQL migrations normally, including foreign keys and unique constraints.
Never include the parent foreign key in editable fields. The parent identity is
trusted route/application input; parent authorization is mandatory.

The URL-encoded wire format is:

```text
lines-TOTAL_FORMS=3
lines-0-id=12
lines-0-description=Updated description
lines-0-quantity=2
lines-1-id=
lines-1-description=New item
lines-1-quantity=1
lines-2-id=15
lines-2-DELETE=on
```

Indexes are zero-based and contiguous. `TOTAL_FORMS` is required exactly once,
canonical decimal, and limited before allocating rows. MaxForms defaults to 100
and must be between 1 and 1000. MaxItems defaults to MaxForms; it limits stored
items, including existing rows omitted from this submission. MinItems defaults
to zero. IDs must be canonical positive integers within the model key type;
blank means new. Integer primary keys are supported; composite/UUID/string keys
are explicitly rejected in this first formset contract.

Duplicate identities, repeated control values, malformed/out-of-range indexes,
invalid typed input and unauthorized rows fail the whole batch. Unknown model
fields cannot cause mass assignment. A deleted row bypasses ordinary required
field validation; deleting an unsaved extra row simply discards it. Blank extra
rows are otherwise validated normally. Omitting a stored row preserves it.
Deletes are disabled unless AllowDelete is true. Boolean delete values are
`on`, `1`, `true`, `0`, `false`, or empty. Prefixes are ASCII identifiers; editable
input names cannot be `id`, `DELETE`, or contain hyphens.

`Bound.Errors()` returns nil or an independent `*formsets.Errors` snapshot.
`Rows[index]` contains field messages (empty field key is row-wide); `General`
contains collection messages. `Values(index)` and `Len()` support redisplay in
your existing typed components. No widget/HTML generation is added. Save-time
validation can also return `*Errors`; other errors are operational and must not
be shown verbatim. Parent authorization failure is `gorm.ErrRecordNotFound`.

## Atomic persistence

Inline locks the authorized parent `FOR UPDATE`, even for empty collections.
Existing submitted child identities are reloaded and locked in key order under
the parent scope before any writes. Item limits are calculated from the stored
collection, not a client-provided initial count. Explicit deletes run first,
then creates/updates run in submission order. This permits replacing a deleted
unique value. Swapping two surviving unique values is not implicitly staged;
PostgreSQL constraints decide whether it is valid.

Every save/delete, GORM hook, model-form check/hook, and formset hook uses the
same outer transaction. Any failure rolls back the entire batch. Results are
returned only after commit: Saved contains persisted rows in submission order;
Deleted contains removed IDs. GORM soft-delete behavior and authored database
cascade rules apply; this package does not invent cascades.

`BeforeDelete` and `AfterDelete` accept `(context.Context, *gorm.DB, *T)`.
Changing deletion identity is rejected. `Validate(context.Context, *gorm.DB)`
is a final cross-row validation hook: query the transaction and return `*Errors`
for safe collection messages. It should be read-only. Model hooks cannot move
saved rows outside the collection; persisted ownership fields are checked.
Limits are also rechecked after hooks. Use the supplied transaction for audit
or join-table writes; defer external side effects until commit or use an outbox.

For other collection structures, supply a `Collection[T]` with a trusted Lock,
Scope, Seed and Protected field names. Lock must serialize all membership changes
(including insertions into an empty collection); Scope filters rows on the supplied
transaction; Seed supplies trusted initial values. All writers—including code
outside this package—must honor the same collection lock for count guarantees.
Normal database deadlock/serialization errors remain operational errors; callers
may retry the complete operation. Repeated submissions are separate operations,
not implicit idempotent requests. Do not construct sets on an existing transaction;
the public Save result assumes ownership of the outer commit.

The core's `Form.InTransaction(tx)` is the general composition seam used here.
Individual model-form results inside that transaction remain provisional until
the outer commit. It accepts PostgreSQL transaction handles only and retains the
original model/naming configuration.

## Verification and boundaries

Tests cover malformed management data, bounded allocation, identity tampering,
scoped parent/child ownership, mixed add/edit/delete, omitted-row preservation,
zero values, replacement under uniqueness, limits, concurrent additions, and
rollback including deletion/audit hooks. A generated independent project
registers the app and exercises the API through HTTP form submissions. Lifecycle
checks verify removal does not perform schema/data cleanup. Tests create isolated
SQL fixtures, not AutoMigrate-managed schemas.

This completes FORM-03 for the documented integer-key collection contract.
Nested inline collections, automatic relation/widget inference, client-side row
editors, string/composite identities
are excluded. FORM-01 widgets/validators are complete; see [forms completion](https://cicadel.fauzaanu.com/en/dev/topics/forms-completion/index.md).
See [TESTING.md](https://cicadel.fauzaanu.com/en/dev/sources/contrib/formsets/TESTING.md) for reproducible checks.

`Model.VersionField` enables per-row `_version` validation for edits and deletes.
`IgnoreEmptyNew` optionally discards wholly empty new rows. `AdminInline[P,T]`
provides a typed inline editor and transactional adapter for `admin.Config[P]`.
Its parent authorization, ownership and child validation are covered by
`admin_test.go`. See the complete forms guide for configuration.
