fix(12.2): close code-review blockers on uploads, JSON caps, and pivot fill

Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-02 23:10:48 +02:00
parent 6bfc0faa8a
commit 516f9c9025
22 changed files with 533 additions and 96 deletions

View File

@@ -24,7 +24,7 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he
- Date and time columns: `lagoon.Date` (a `DATE` column, JSON `"2026-10-02"`) and `lagoon.TimeOfDay` (a `TIME` column, JSON `"14:30:00"`) implement `sql.Scanner`, `driver.Valuer`, JSON and text marshalling, and store NULL for their zero value; `*lagoon.Date` and `*lagoon.TimeOfDay` are the nullable variants, next to `time.Time` and `*time.Time` for `timestamptz`. Build them with `lagoon.NewDate`, `lagoon.DateOf`, `lagoon.ParseDate`, `lagoon.NewTimeOfDay` and `lagoon.ParseTimeOfDay`. `lagoon.Fill` fills all six from JSON strings (RFC 3339 for `time.Time`) through their text unmarshalling, after every conversion it already made. Behaviour change: `required` now treats a zero `time.Time`, `lagoon.Date` or `lagoon.TimeOfDay` (or a pointer to one) as empty, so declare optional dates as pointer fields.
- Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value.
- Lifecycle and relations: hook interfaces matching GORM's native method names (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) plus `lagoon.HasBeforeValidate`; `lagoon.WithSoftDeleteCascade` runs a cascade inside the parent delete; `lagoon.RegisterJoinTable` wires pivot models with business columns.
- Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...}}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`.
- Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...},"upload_id":"..."}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`.
- Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports.
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), public URLs (`attach.PublicURL` for any key, `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since WebP cannot be encoded; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming and, in image mode, the `attach.IsAllowedImage` content guard), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
@@ -133,7 +133,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `lagoon.DeferredHistoryID` | History id of the deferred-binding set, `summercms.deferred`. |
| `lagoon.DeferredBinding` | The `deferred_bindings` row model; `lagoon.DeferredBinding.Envelope` decodes its `pivot_data`. |
| `lagoon.DeferredKey` | Session key, admin id and master type that scope every deferred-binding operation. |
| `lagoon.DeferredEnvelope` | The framework's `pivot_data` shape: `Created` marks a child created under deferral, `Pivot` holds pivot values. |
| `lagoon.DeferredEnvelope` | The framework's `pivot_data` shape: `Created` marks a child created under deferral, `Pivot` holds pivot values, `UploadID` is the client upload id of a deferred file. |
| `lagoon.DeferredFileType` | The `slave_type` of a binding that points at a `system_files` row. |
| `lagoon.MorphType` | The `master_type` or `slave_type` string of a model: its `attach.Owner` morph name, else its table name. |
| `lagoon.DeferredBind` | Records a pending bind; a repeat writes nothing and a pending unbind of the same slave is cancelled. |

View File

@@ -69,8 +69,9 @@ func (k DeferredKey) validate() error {
// holds the pivot values of a deferred belongsToMany link. A binding without
// the envelope (nil pivot_data, or JSON without these keys) is a plain link.
type DeferredEnvelope struct {
Created bool `json:"created,omitempty"`
Pivot map[string]any `json:"pivot,omitempty"`
Created bool `json:"created,omitempty"`
Pivot map[string]any `json:"pivot,omitempty"`
UploadID string `json:"upload_id,omitempty"`
}
// Envelope decodes the binding's pivot_data. Nil or empty pivot_data is the
@@ -167,7 +168,7 @@ func insertBinding(ctx context.Context, tx *gorm.DB, key DeferredKey, field, sla
IsBind: bind,
BackendUserID: key.AdminID,
}
if env != nil && (env.Created || len(env.Pivot) > 0) {
if env != nil && (env.Created || len(env.Pivot) > 0 || env.UploadID != "") {
raw, err := json.Marshal(env)
if err != nil {
return fmt.Errorf("lagoon: deferred binding envelope: %w", err)

View File

@@ -362,15 +362,19 @@ func TestDeferredStore(t *testing.T) {
if err := DeferredBind(ctx, tx, key, "members", "acme_members", "2", &DeferredEnvelope{Pivot: map[string]any{"note": "hi"}}); err != nil {
return err
}
// An empty envelope stores no pivot_data.
return DeferredBind(ctx, tx, key, "members", "acme_members", "3", &DeferredEnvelope{})
// An empty envelope stores no pivot_data; upload_id alone is stored.
if err := DeferredBind(ctx, tx, key, "members", "acme_members", "3", &DeferredEnvelope{}); err != nil {
return err
}
return DeferredBind(ctx, tx, key, "photos", "system_files", "4", &DeferredEnvelope{UploadID: "retry-one"})
})
rows := bindingRows(t, gdb)
if len(rows) != 3 || rows[0].PivotData == nil || *rows[0].PivotData != `{"created":true}` ||
rows[1].PivotData == nil || *rows[1].PivotData != `{"pivot":{"note":"hi"}}` || rows[2].PivotData != nil {
if len(rows) != 4 || rows[0].PivotData == nil || *rows[0].PivotData != `{"created":true}` ||
rows[1].PivotData == nil || *rows[1].PivotData != `{"pivot":{"note":"hi"}}` || rows[2].PivotData != nil ||
rows[3].PivotData == nil || *rows[3].PivotData != `{"upload_id":"retry-one"}` {
t.Fatalf("pivot_data %+v", rows)
}
for i, want := range []DeferredEnvelope{{Created: true}, {Pivot: map[string]any{"note": "hi"}}, {}} {
for i, want := range []DeferredEnvelope{{Created: true}, {Pivot: map[string]any{"note": "hi"}}, {}, {UploadID: "retry-one"}} {
env, err := rows[i].Envelope()
if err != nil || fmt.Sprint(env) != fmt.Sprint(want) {
t.Fatalf("envelope %d = %+v %v", i, env, err)