JSON Encoding¶
PlainShelf has moved from encoding/json to encoding/json/v2. This page is
what the conversion followed and what new code follows: which option set a call
site marshals with, what each v1 API becomes, and which v2 defaults are adopted
on purpose.
Written for PSW-95. The conversion is complete: since PSW-99 no file in the
repository imports encoding/json, so a call site copied from anywhere else is
already v2.
Why the API changes at all¶
Go 1.27's encoding/json already runs on the v2 implementation, so switching
imports buys no performance. It buys a deadline instead.
The behaviors worth having — empty arrays instead of null, & and <
written as themselves, duplicate object members rejected — are v2 defaults,
and they change bytes. Changing bytes is cheap now and expensive after the
1.0.0-rc1 freeze, when every writer would have to carry v1-compatibility
options forever to avoid a format change. That end state is the worst one
available: the v2 API with v1 semantics, paying for the migration without
collecting anything for it.
So the conversion happens before the freeze, in one series, and each batch takes the new defaults as it goes.
The option sets¶
Every marshal goes through internal/jsonopt. It exports three sets, all
carrying json.Deterministic(true):
| Set | Output | Use it for |
|---|---|---|
jsonopt.Disk() |
indented two spaces | files a reader can open and edit: book.json, a source's meta.json, trash metadata, the exported book cache |
jsonopt.DiskCompact() |
one line | the machine-only files under app/: the fingerprint cache, the scan cache, stored reading progress, the values in the settings table |
jsonopt.API() |
one line | HTTP response bodies |
All three modules — the root module, desktop, and reader — import it
directly; internal/ under the root module path is visible to both nested
modules through their replace directives.
Why determinism is not optional¶
v2 marshals map entries in unspecified order. v1 always sorted them, and two mechanisms in the shelf are built on "same content, same bytes":
shelf/fingerprint/cache.gocompares the freshly encoded cache against the bytes already on disk and skips the write when they match.cacheFileholds two maps.shelf/shelf_cache_export.gowrites the exported book cache only whenbookCacheDigestchanges. That digest is a hash of a payload containingBooks map[string]BookCacheEntry.shelf/scancache/cache.godoes the same forapp/scan-cache.jsonthroughscanCacheDigest.
Omitting Deterministic breaks none of them loudly. It compiles, it round-trips,
and a single-run golden test still passes. What it does is make every scan
rewrite files that did not change — which on a shelf held on pCloud or SMB is a
re-upload per scan, forever, on someone else's machine.
That is why the option lives in one package and why the assertions below exist.
v1 → v2 API mapping¶
The v2 package is itself named json, so it needs no import alias — a call
site keeps reading as json.Marshal:
encoding/json |
encoding/json/v2 |
|---|---|
json.Marshal(v) |
json.Marshal(v, jsonopt.Disk()) — same name, options added |
json.Unmarshal(b, &v) |
unchanged |
json.MarshalIndent(v, "", " ") |
json.Marshal(v, jsonopt.Disk()), which carries jsontext.WithIndent(" ") |
json.NewEncoder(w).Encode(v) |
json.MarshalWrite(w, v, jsonopt.API()) |
json.NewDecoder(r).Decode(&v) |
json.UnmarshalRead(r, &v) |
json.RawMessage |
jsontext.Value |
json.Valid(b) |
jsontext.Value(b).IsValid() |
decoder.DisallowUnknownFields() |
json.RejectUnknownMembers(true) as an option |
json.Marshaler / json.Unmarshaler |
unchanged — v2 still honors the v1 interfaces, which is why util.JSONTime and util.JSONDate need no work |
Two shape differences to expect while converting:
MarshalWritewrites no trailing newline, whileEncoder.Encodedid. A response body does not care; a file compared byte-for-byte does.UnmarshalReadconsumes the whole reader and rejects trailing data, whereDecoder.Decodestopped after one value.shelf/shelf_config.goused to detect trailing junk inshelf.jsonwith a secondDecode; converting it deleted that check, becauseUnmarshalReadalready refuses the same file.
v2 defaults: adopted or overridden¶
internal/jsonopt sets determinism and indentation and nothing else, so every
row below marked "adopt" is live simply by not being overridden — on the read
side too: the shelf makes no compatibility promise about a file a v1 build
wrote and a v2 build reads.
| v2 default | Effect here | Decision |
|---|---|---|
| Map entries in unspecified order | Defeats the three "unchanged, do not rewrite" checks above | Override. Deterministic(true) in all three sets |
nil slice and map encode as [] and {} |
Settles the "authors": null question PSW-35 left open; a dozen or so API fields stop returning null. The Android pCloud reader already accepts both |
Adopt (PSW-97, PSW-98). See the API's array contract |
<, >, & are not escaped |
An escape sequence in a file whose selling point is that a text editor shows what you typed is a defect. On disk only golden fixtures change; the two places that inject JSON into an inline <script> escape < themselves (see below) |
Adopt (PSW-97, PSW-100) |
| Object names match case-sensitively | A hand-edited "Title" stops being read, and setMeta rewrites the file whole, so the next save drops it. The unknown-member passthrough PSW-93 wants is what turns that into a preserved field |
Adopt (PSW-97). Documented by PSW-99; the passthrough that stops the loss is still PSW-93's |
| Duplicate object members rejected | Pure gain for a hand-editable format, provided the error names the file and the member | Adopt (PSW-97). PSW-99 added the file name: see bookpkg.MalformedMetadataError |
| Invalid UTF-8 rejected | Same | Adopt (PSW-97), reported the same way |
omitempty means "would encode as null, empty string, empty object or empty array" |
Every use is on a string, slice, map or pointer field; PSW-40 already moved the bool and int fields to omitzero |
Adopt. No visible change |
Not done, deliberately:
GOEXPERIMENT=nojsonv2— an escape hatch that upstream plans to remove.- Carrying v1-compatibility options to preserve existing bytes, which is the outcome the whole series exists to avoid.
jsontextstreaming decode for largebook-cache-*.jsonfiles, and rewritingutil.JSONTime/JSONDateasMarshalerTo/UnmarshalerFrom. Both are real, neither is urgent; open a ticket when there is a measurement.
The API's array contract¶
Every HTTP response body is marshalled by one of two writers — writeJSON in
server/apicore.go and writeJSON in reader/readerapi/api.go — and both go
through jsonopt.API(). An array-valued field therefore reaches the client as
[] when it is empty, and never as null. A client can index it without a
?? [] guard of its own.
The desktop client is outside this: its Wails bindings return Go values that Wails serializes with its own encoder, which this repository does not configure.
The exception is omitempty, which v2 defines as "would encode as null, empty
string, empty object or empty array": a field carrying it is absent when
empty rather than [], so the guard is still needed there. Which fields carry
it is the whole of the difference:
| Field | Empty value on the wire |
|---|---|
authors, folder, succeeded_ids, failures, tasks, conflicting_book_ids |
[] |
tags, identifiers, and the trash listing's authors and original_folder |
absent |
server/contract pins both halves — assertJSONArray for the first row and
TestAPIOmitEmptyMetaFieldsStayOmittedContract for the second — because the
difference is invisible in Go, where a nil and an empty slice decode alike.
Request decoding is unaffected: a *[]string field left nil by an absent member
is also left nil by an explicit null, in v1 and v2 alike. Clearing a list
through PATCH is spelled [], and null means "leave this alone".
JSON inside an inline <script>¶
Two handlers write a marshalled payload straight into index.html inside a
<script> element: injectSecurityBootstrap in server/spa.go, which carries
the local token and the configured token_header, and indexHTML in
reader/readerapi/spa.go, which carries the open book's ID. Both replace <
with \u003c after marshalling, and that replacement is load-bearing: v1 escaped
< in every string it wrote, v2 does not, and < is the only character that can
close the element. \u003c survives JSON.parse unchanged, so nothing else has
to know.
token_header comes from the config file and a book ID is a directory name, so
neither is a value this repository chose. TestInjectSecurityBootstrapEscapesScriptClose
and TestSPAEscapesScriptCloseInTheBootConfig fail if the replacement is dropped.
What the tests enforce¶
Determinism, per write path. Each of the three "unchanged content, do not
rewrite" checks has a case that writes the same data eight times over and fails
if the bytes or the file's mtime move: TestFingerprintCacheWithManyEntriesIsByteStable,
TestBookCacheExportOfManyBooksSkipsUnchangedContent and
TestScanCacheWithManyDirectoriesIsNotRewritten. Each uses twelve to sixteen
map entries, because a map with one entry has no order to vary and passes with
or without the option; removing jsonopt.DiskCompact() from one write path
fails that path's case and no other.
Strict reading, per file. The three shelf files a person edits by hand —
book.json, a source's meta.json, trash.json — refuse a duplicate member,
invalid UTF-8, and trailing data, and name the file that failed:
TestOpenBookRejectsDuplicateMemberAndNamesTheFileAndMember and its neighbours
in shelf/bookpkg/metaerror_test.go. shelf.json is read as strictly and
falls back to the built-in defaults instead
(TestShelfConfigMalformedFallsBackToDefaults). The caches under app/ are
deliberately outside that: TestFingerprintCacheWithDuplicateMemberIsDiscardedNotReported
and TestScanCacheIgnoresUnreadableSnapshot pin that an unreadable one is a
cache miss rather than a failure.
Left for the conversion tickets¶
- The
json:"…"struct tags need no edits. v2 reads the same tag syntax, includingomitemptyandomitzero. - The unknown-member passthrough is not built. Until PSW-93 lands, a key outside
the schema — including a
"Title"that case-sensitive matching turned into one — is dropped by the next write to that book.docs/concepts/data-format-versioning.mdis what tells a user so.