Skip to content

fix: persist reviver no longer corrupts ordinary strings into Dates (TODO 9) - #96

Closed
lopugit wants to merge 2 commits into
mainfrom
claude/fix-date-reviver-corruption
Closed

fix: persist reviver no longer corrupts ordinary strings into Dates (TODO 9)#96
lopugit wants to merge 2 commits into
mainfrom
claude/fix-date-reviver-corruption

Conversation

@lopugit

@lopugit lopugit commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Fixes TODO item 9 — 🐛 DATA CORRUPTION: the persist reviver turns ordinary strings into Dates.

Problem

ThingtimeProvider's localforage reviver revived any string that passed V8's lenient Date.parse into a Date. Everyday values like "Post 1", "1", "2024", "March 2024", "5 April" became Date objects on reload; the replacer then rewrote them as ISO strings — permanently corrupting user data after one save/reload cycle (and Date objects could reach React render paths).

Fix

  • Replacer: Dates now persist tagged as {ttype:'date', iso}, mirroring the existing ttype:'function' scheme. Subtlety: JSON.stringify (which flatted wraps) runs Date.prototype.toJSON before the replacer, so the replacer's old value instanceof Date branch was unreachable — Dates were actually persisting as bare ISO strings, which is exactly why revival had to guess. The fix reads the original Date off the holder (this[key]), the standard escape hatch for this; flatted 3.4.2 forwards the holder correctly (verified in its source).
  • Reviver: revives tagged {ttype:'date'} values, plus — for backward compatibility with already-persisted state — bare strings that exactly match the old replacer's toISOString() output (YYYY-MM-DDTHH:mm:ss.sssZ). Everything else stays a string.
  • Invalid Dates (isNaN(getTime())) are never tagged; a tagged value with an unparseable iso degrades to the raw string instead of throwing.

Known accepted tradeoff: a user string that exactly matches full toISOString() format still revives as a Date (required to keep legacy persisted Dates working). Near-ISO strings ("2024-03-05", "2024-03-05T12:34", "...+02:00") now stay strings.

Verification

  • 27-case node test against real flatted 3.4.2: ordinary strings survive roundtrips; Dates roundtrip as Dates; legacy-format data keeps its Dates while "Post 1"/"2024" are no longer corrupted; double roundtrip is stable; functions still tag; circular refs still work; invalid Dates don't crash.
  • Live browser (worktree dev server, ports 16170-16172): app boots with zero console errors; in-page window.flatted roundtrip checks all pass; end-to-end setThingtime(['settings','x'], 'March 2024') + a real Date → debounced autosave → reload → hydrate: the string survives as a string, the Date as a Date (previously the string came back as a Date, then an ISO string).

Note: the eval-based ttype:'function' revival directly below this code is TODO item 10 (security: eval + CSP) and intentionally untouched here — whoever picks it up should rebase on this since we share the reviver/replacer.

🤖 Generated with Claude Code

TODO item 9 (data corruption). The localforage reviver revived ANY string
that passed V8's lenient Date.parse into a Date ("Post 1", "1", "2024",
"March 2024", "5 April"), and the replacer then rewrote them as ISO strings
- permanently corrupting user data after one save/reload cycle.

Dates now persist tagged as {ttype:'date', iso}, mirroring the existing
ttype:'function' scheme, so revival never guesses from string shape. Because
JSON.stringify runs Date.prototype.toJSON before the replacer, the original
Date is read from the holder (this[key]) - the old `value instanceof Date`
branch was unreachable and Dates actually persisted as bare ISO strings.

Backward compatibility: bare strings that exactly match the old replacer's
toISOString() output (YYYY-MM-DDTHH:mm:ss.sssZ) still revive as Dates, so
previously persisted state keeps its Dates. Near-ISO user strings
("2024-03-05", "2024-03-05T12:34", offset timestamps) now stay strings.

Verified: 27-case node test against real flatted 3.4.2 (legacy data, new
roundtrip, circular refs, functions, invalid Dates); live browser check on
the worktree dev server - setThingtime('March 2024') + a real Date both
survive a persist -> reload -> hydrate cycle correctly, zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thingtime Ready Ready Preview, Comment Jul 26, 2026 12:52pm

Request Review

@lopugit

lopugit commented Jul 21, 2026

Copy link
Copy Markdown
Owner Author

Duplicate-work notice (parallel sessions): PR #94 and PR #96 both fix TODO 9 — they raced before the shared claims protocol (TODO/.claims/, created by session 1 mid-run) existed. One should be merged and the other closed. A precise comparison to help pick:

Where they agree — both revive bare strings only when they exactly match toISOString() output (YYYY-MM-DDTHH:mm:ss.sssZ) with a Date.parse validity check, both keep legacy persisted Dates working, both leave the ttype:'function' eval path (TODO 10) byte-identical, and both were verified against real flatted plus a live browser persist→reload cycle.

Where they differ — the persist format going forward:

Recommendation: merge #96 (tagged format is strictly more future-proof and matches the TODO's suggested scheme), and cherry-pick #94's nice TODO/TODO.md ✅-annotation hunk. Merging #94 instead is also safe — the formats are compatible one-way (#96's reviver reads #94's output, not vice versa), so #94#96 later would work, #96#94 would strand tagged saves.

(Posted by session 1; identical comment on both PRs.)

@lopugit

lopugit commented Jul 21, 2026

Copy link
Copy Markdown
Owner Author

Triage note (session 3/10): this duplicates TODO 9 with #94 and #98#94 was the first claim (claim branch pushed ~23 min before this PR) and carries a 26-check flatted suite + live browser load→autosave verification. Owner should pick one and close the others.

lopugit added a commit that referenced this pull request Jul 30, 2026
…98/#106)

Upgrade the persist codec from strict-ISO string revival to the tagged
scheme the duplicate TODO-9 PRs used, closing the remaining false-positive:

- Real Dates persist as {ttype:'date', iso}. The replacer reads the
  original off the holder (this[key]) because Date.toJSON converts Dates
  to strings before the replacer sees the value.
- A USER string that merely looks like a full ISO timestamp is escaped as
  {ttype:'iso-string', s} so the legacy fallback can never capture it —
  previously such a string became a Date on the next load.
- The legacy bare-ISO fallback remains only to migrate pre-tagging
  persists.
- Both replacer and reviver skip their own wrappers' inner keys (without
  the guard the escape rule re-wraps its own output unboundedly, and the
  legacy fallback hands the wrapper branches a Date instead of the
  original string — caught by the new tests).

Tests: 9/9 in test:persist, including Date + identical-looking string
coexisting, two-cycle string stability, and hostile-payload drops.
TESTING.md gains the register rate-limit checklist (from #106) and a
persisted-codec checklist. Live-verified: app hydrates under the CSP,
persists and reloads cleanly, zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lopugit

lopugit commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Consolidating the TODO 8/9/10 security PRs: #99 is the single winner and now carries this PR's unique value (see the consolidation note on #99 for exactly what was folded in and why). Closing as a duplicate.

@lopugit lopugit closed this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant