diff --git a/CONTENT_CODEC_SPEC.md b/CONTENT_CODEC_SPEC.md index c909e154c..a50a13103 100644 --- a/CONTENT_CODEC_SPEC.md +++ b/CONTENT_CODEC_SPEC.md @@ -174,7 +174,11 @@ S.base64.with(S.to, S.string); // identity — a string is NOT bytes | `S.base64` | bytes | sync | sync | | `S.base64url` | bytes | sync | sync | | `S.jsonString` (future: toon, env) | a JSON value | sync | sync | -| future: `S.formData`, protobuf | a record / a message | sync | sync | +| future: protobuf | a message | sync | sync | + +`S.formData` is not a carrier on this axis: a form has no JSON document form +and no format opens into one, so a link to it has one reading or none, and a +`FormData` in a JSON position has no document, the way a `Blob` has none. Packing bytes into a JSON position always produces base64. Packing a `File` loses its name — the reverse builds `new File([content], "")`; a name option diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5248e18c..e06d8b9c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -335,15 +335,12 @@ case the harness *should* have caught or guided better — a missing check, a we error message, a strictness gap that let a bad spec through — add a bullet here instead of silently working around it. -- An operation whose output holds a `Blob` or `File` (`S.blob`/`S.file` - decoding, or the reverse of any conversion into them) can't be specced: the - golden writer raises "cannot represent a Blob instance as spec source code", - and an op has no way to opt out. `Uint8Array` is written as a constructor - call, but a binary container's bytes are only readable asynchronously, so the - writer would have to await the example before rendering it. It costs a whole - direction of the content axis: the `codec-*` specs for `S.blob` and `S.file` - carry codegen and error cases only, and `tests/content_test.ts` holds the - values instead. +- A `Blob`, `File` or `FormData` in an example's output renders without its + `lastModified`: that field defaults to the moment the value was built, so + recording it would rewrite the golden on every run. Two values that differ + only there therefore write the same golden. Everything else round-trips — + bytes as the text that produced them where that is printable, and as a + `Uint8Array` otherwise. - An example's `error` is matched verbatim, so one raised by the *platform* rather than by Sury pins that engine's wording: `new Blob([Symbol()])` says "Cannot convert a Symbol value to a string" on Node 22 and "The argument diff --git a/IDEAS.md b/IDEAS.md index 21594eff7..590301f2b 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -161,22 +161,44 @@ of a form-data story. What they were built to make cheap, roughly in order: fail with `invalid_operation`. `advanced/uint8Array.ts` is the shape to copy. The payoff is `S.file.with(S.to, S.jsonString.with(S.to, configSchema))` — parse an upload into a typed value, and reverse it to *build* the upload. -- **`S.formData` as a codec, not a preprocessor.** A `FormData` field is - `string | File`, so the per-field work is the existing string coercions plus - `.get`/`.getAll` extraction; the object rebuild in `advanced/json.ts` - (`jsonDecoderFn`, via `makeObjectVal`/`B_addObjectField`) is the pattern. - Reversing it emits `new FormData()` + `append` per field, which is what makes - this different from VineJS and every other form validator: one schema serves - the request handler *and* the `fetch` body. `S.urlSearchParams` is the same - code minus files, and `S.queryString` is to it what `S.jsonString` is to - `S.json`. -- **The three HTML-form quirks**, once `S.formData` exists: a checkbox is absent - when unchecked and `"on"` when checked (VineJS spells this `vine.accepted()`), - an empty text input submits `""` rather than nothing, and repeated keys are - how arrays arrive. The first wants a named `S.accepted`; the second belongs to - the codec rather than a global flag, since it's a wire quirk; the third is - `.getAll`. Bracket notation (`user[name]`) is deliberately out — VineJS leans - on `qs` for it too. +- **`S.urlSearchParams` and `S.queryString`**, now that `S.formData` has shipped. + The codec only calls `get`/`getAll`/`append`, all of which `URLSearchParams` + has, so the first is the same code minus files and the second is to it what + `S.jsonString` is to `S.json`. +- **A `S.record` target for the same readers**, for a form whose keys aren't + known ahead of time. `S.formData.with(S.to, S.record(S.string))` is rejected + today: the codec takes the object path only when `additionalItems` is + `"strip"`/`"strict"`, and a record's is the value schema, so the pair falls + through to `Can't decode FormData to { [key: string]: string; }`. Two things + to settle before it can be written: + - **What a repeated key becomes.** The declared path answers `getAll` for a + `S.array` field and `get` for every other, which a record has no field to + ask. `Object.fromEntries(fd)` keeps the last value and loses the rest; + `S.record(S.array(V))` keeps them but wraps the common case in a + one-element array. A third reading — `getAll` where the value type is an + array and `get` otherwise — matches the declared path exactly and is + probably the one. + - **Which value types can work.** Only ones a text wire can discriminate: + `S.record(S.string)` and `S.record(S.number)` are fine, and + `S.record(S.union([S.string, S.number]))` can't be — the union rules reject + `string -> string | number` before the codec is consulted, since every + entry satisfies the string arm. +- **`string -> string | undefined` is still rejected by the union rules**, so + the env pattern can't read an optional string field — where the form codec + converts the present arm itself. Pinned by + `specs/dict-to-object-optional-string`. +- **Nested keys for `S.formData`, with no API to turn them on.** Nesting the + schema is the switch: `S.schema({ user: S.schema({ city }) })` rejects the + pair today, and instead should read `user[city]`. Brackets only — PHP + invented the spelling, Rails, `qs` and Express read it, and a plain `