Skip to content

Tag ambiguous strings as string and keep JSON null in nested Loro values - #2206

Merged
joepio merged 5 commits into
developfrom
claude/project-thread-3oq7ug
Oct 11, 2026
Merged

joepio merged 5 commits into
developfrom
claude/project-thread-3oq7ug

Conversation

@joepio

@joepio joepio commented Oct 10, 2026

Copy link
Copy Markdown
Member

Before: a string property whose value starts with [, {, atomic:, did:ad: or http(s):// was stored untagged, and readers guessed its type, so ["track","osc"] came back as a nested resource and https://example.com as a URL. JSON null inside nested maps and lists was silently dropped on write.

After: such strings get the string tag in the Loro datatypes map and read back as plain strings in Rust and TS. JSON null survives a round trip in nested maps and lists.

This is the one part of #2045 (frozen app schemas) that #2151 did not carry over, and it affects all data, not only schemas. Atomic Audio relies on it, because it uses JSON-array strings as entity IDs.

How: datatype_tag (Rust) and datatypeTag (TS) tag ambiguous strings, and atomic_value_from_tag reads the tag. The Rust json_value_to_loro_* helpers and the TS writeJsonToLoroMap/List write null instead of skipping it. Older readers don't know the string tag and fall back to their old guess, which is no worse than today.

Related Issues

Part of #2045, which #2151 replaced.

Checklist

  • Add changelog entry linking to issue, describe API changes
  • Add or update tests if needed (Rust: ambiguous_strings_preserve_their_declared_type, json_null_is_preserved_in_nested_maps_and_lists; TS: datatypes.test.ts, a pushListItem null test that fails without the fix)
  • Update docs if needed

Local: atomic_lib lib tests 774 passed, atomic-server lib tests 386 passed, @tomic/lib 1516 passed.


Generated by Claude Code

joepio commented Oct 10, 2026

Copy link
Copy Markdown
Member Author

Complexity and compatibility review (+87/-6 over 6 files; I read the diff, not the surrounding reader code or the signing path). Since this touches how all new values are written, I'd hold the merge for items 1 and 2.

  1. The "ambiguous string" rule exists twice and the copies already differ. Rust datatype_tag (lib/src/loro.rs ~918-928, via is_atomic_identifier) excludes atomic://; the TS regex in browser/lib/src/datatypes.ts (~79-83) tags atomic: followed by anything, including atomic://. So the tag written depends on which side wrote the value. Use atomic:(?!//) in TS, and put the examples in one shared fixture list that both the Rust and TS tests read; nothing tests parity today.
  2. Backward compatibility is claimed, not shown. Old stored documents go through the unchanged fallback, so they should materialize identically, but there is no test with an old untagged snapshot. Newly written commits carry extra datatypes entries and Null ops, and I have not seen evidence that released readers (older Rust/WASM/TS) ignore an unknown string tag and tolerate null inside nested maps and lists. A compat test that feeds a post-PR delta to a pre-PR reader build is the cheapest check; given the standing rule that old clients keep working, I would want that before this lands.
  3. TS has no test that a ["a","b"] string carrying the string tag reads back as a plain string; the TS tests only cover the tag function and the write side.

The rest is proportionate: null handling is a one-line arm in each helper and no new layers are added.


Generated by Claude Code

…ompat tests

The TS datatypeTag tagged atomic:// links while Rust did not. The untagged
reader leaves atomic:// as a plain string, so TS now excludes it too. Both
sides test against lib/test_files/string-datatype-tags.json.

Add Rust tests for old untagged snapshots, unknown tags and Null in nested
containers, and TS tests for unknown tags and nested Null.

joepio commented Oct 10, 2026

Copy link
Copy Markdown
Member Author

Both holds are addressed in 5d36809.

  1. atomic://: Rust was right. The untagged reader (loro_value_to_atomic_value) leaves atomic://x as a plain string because is_atomic_identifier excludes it, so nothing needs pinning. TS now uses atomic:(?!\/\/). Both sides test against one fixture, lib/test_files/string-datatype-tags.json (17 cases), read by string_datatype_tag_matches_shared_fixture in Rust and by datatypes.test.ts.
  2. Backward compatibility:
    • An old untagged snapshot still materializes through the heuristic as before.
    • An unknown tag falls back to the heuristic without an error. On develop, atomic_value_from_tag ends in _ => None and the caller falls through, so this is the path a pre-PR reader takes on the new string tag.
    • Null in nested maps and lists reads without error. loro_value_to_json and loro_value_to_atomic_value are unchanged from develop, so this also covers old readers.
    • TS has the same tests for an unknown tag and for nested Null.

Locally: loro tests 64 passed, clippy -D warnings clean, @tomic/lib 1533 passed, lint clean.


Generated by Claude Code

joepio added a commit that referenced this pull request Oct 10, 2026
The Dagger lib unit-test container only mounts browser/lib, so the TS test
could not read lib/test_files. Rust now reads it with include_str!, like
authentication_v2_vectors.json.
joepio added a commit that referenced this pull request Oct 11, 2026
* Sign incremental commits as Loro deltas, not full snapshots

CommitBuilder::sign_at rebuilt the doc from the resource's existing
snapshot, applied the pending set/remove, and signed
export_snapshot(). Every incremental commit therefore carried the whole
document in loroUpdate, so its size grew with the document (and with
every Loro peer that had touched it). Cloud Vault delta packs ship
envelopes, so their per-edit cost grew too: over 40 edits of one note,
latest retention went 4171 -> 16378 bytes per pass and all retention
7063 -> 31687.

sign_at now exports only the changes since a base version when the
resource has prior state; genesis / new resources still sign a snapshot.
The base is AtomicLoroDoc::tokened_base_vv of the persisted snapshot:
the version of the newest tokened change, not the full version. A node
stamps lastCommit into the doc after applying a commit, as an untokened
local change no signed commit carried; the next edit builds on it, so a
delta from the full version made the server park it as pending for a
missing dependency (caught by multi_client_gallery_sync). Exporting from
the tokened base carries the stamp along. Legacy docs without any token
keep signing snapshots. sync_loro_changes_to_commit_builder uses the same
base. The sign() fallback that exported the open doc now exports the
delta too.

Measured with vault_envelope_growth (40 edits): last-edit delta now 1864 (latest)
and 3080 (all) bytes instead of 16275 and 32068, flat across edits. The test now
asserts the last delta stays within 1500 bytes of the first. The
planning table is refreshed from vault_incremental_cost.

The vault envelope floor stays >= (the previous pass's newest envelope is
re-shipped once): dedupe would need envelope identity, and a strict >
could lose a commit sharing the same millisecond.

* Don't re-enroll an already hosted drive when opening the sync page

The sync page auto-enrolled any drive the account had enrolled until the
node-side proof of hosting (live sync + drive usage) arrived. On a drive
already on Cloud Server that re-ran enrollment, reconnected the socket
(a spurious 'Working offline' toast) and announced 'Connected to Cloud
Server'. Skip it when the current server is already a managed node, and
read the user-disconnected flag per event in NetworkIndicator.

* Cloud Vault: compress the backup and discard its history

Compress now forces the next backup pass to be a full checkpoint, publishes
it, then frees what it replaced. Discard history does the same and also
frees the undo window, behind a confirmation dialog. Free-up never runs if
the checkpoint did not land. The forced pass reuses the exporter's
"no anchor in the vault" input, so cursors and lane state are unchanged.

* Cloud Vault: say what discarding backup history really frees

A checkpoint still holds each resource's whole Loro history, so the old
copy about keeping only the current state over-promised. Discard history
removes older copies, the delta chain and the undo window.

* Cloud Vault: download the backup to a file and restore from one

"Download backup" saves the drive's sealed vault objects as one
.atomic-vault file (versioned header, then the objects in listing order,
each with a SHA-256). "Restore from file" validates it and feeds the
objects through the same import as a cloud restore, which now takes an
injectable object source. Nothing is uploaded; the file stays sealed and
needs the drive key.

* Vault: Discard history keeps only the current state in the backup

A checkpoint can now export each resource as a Loro shallow snapshot at its
current frontiers and ship no signed envelopes (HistoryKeep::CurrentStateOnly).
Default stays Full, so ordinary passes and Compress now are unchanged. Threaded
through the WASM, worker and Tauri export entries and the Discard history flow.

Shallow docs export a snapshot to peers behind the cut instead of a delta that
would be parked as pending, fork_at on a shallow doc is a clear error, and the
History page tolerates a shallow doc. Copy, locales and the format doc updated;
tests cover restore, sync with a full-history device and size.

* Keep a conversation with only yourself on this device

startConversation(store, []) is now a note to yourself: the only member is
the creator, and the drive is registered browser-only before the first
save, so its genesis never enters the outbox and a managed node is never
asked to host it. A non-empty list that holds only yourself still throws.

healRefusedDrive may now move a conversation whose read list is exactly the
signed-in agent. Every other conversation is still never moved.

* Add file encryption for conversation attachments

* Tag ambiguous strings as string and keep JSON null in nested Loro maps and lists

* Share string datatype tag fixture between Rust and TS; add backward-compat tests

The TS datatypeTag tagged atomic:// links while Rust did not. The untagged
reader leaves atomic:// as a plain string, so TS now excludes it too. Both
sides test against lib/test_files/string-datatype-tags.json.

Add Rust tests for old untagged snapshots, unknown tags and Null in nested
containers, and TS tests for unknown tags and nested Null.

* Report one Sentry session per page load, not one per route change

* Ask the control plane about the session once, not on every retry and flush

A signed-out visitor called /api/me about nine times in the first 25 s and
then every few seconds for as long as the page stayed open: the hosted-AI
status retried, the vault backup scheduler re-queued, and every other caller
asked again. getManagedAccount now serves the last settled answer for 30 s,
forgets it on focus and when a sign-in lands in this tab, and callers that
wait for a sign-in read fresh.

* Ask for a missing external property once, and bundle the demo speaker property

A property the remote answers 404 for was fetched again on every put of a
message that carries it, twice per miss (the fetch, then handle_not_found
fetching the same URL). Db now remembers a definitive 404/410 for the
session and no longer retries it; 5xx and network errors are still retried.
The demo speaker property is added to the bundled defaults so it resolves
locally and is never asked for.

* Let a remembered 404 expire after five minutes, and share one listener registry

Review of the redundant-requests PR: a property that 404ed once stayed
missing for the life of the Db. The memory now expires after 5 minutes.
Also proves a failed session read is not cached, merges the two listener
sets in session.ts, and states what the not-found-once test covers.

* Format the remote-not-found test server response

* Format and pad the combined batch's frontend changes for lint

* Take #2206 out of the combined batch: its datatypes test reads a fixture outside browser/lib

* Forget the cached session before each refused-drive test

* Integration tests: close agent-profile-sync's stores before the servers go

Same teardown failure as defb758: stores left connected log after the file
finishes, and vitest exits 1 with every test green.
The Dagger Rust container does not mount browser/lib/src, so include_str!
failed. lib/tests/fixtures is where the other Rust/TS shared fixtures live and
is mounted in both the Rust and the lib unit-test containers.

joepio commented Oct 11, 2026

Copy link
Copy Markdown
Member Author

Run 5206 failed on c670647 because atomic_lib (lib test) did not compile. include_str! could not read browser/lib/src/string_datatype_tags.json, since the Dagger Rust container does not mount that file. This PR caused it.

Fixed in fee0236: the fixture now lives in lib/tests/fixtures/string-datatype-tags.json. That is where the other shared Rust/TS fixtures live (class constraints, JSON Schema interop), and both the Rust container and the lib unit-test container mount it. Locally the Rust fixture test, datatypes.test.ts (20) and lint pass.


Generated by Claude Code

@joepio
joepio marked this pull request as ready for review October 11, 2026 06:59
@joepio
joepio merged commit bc493c2 into develop Oct 11, 2026
8 checks passed
@joepio
joepio deleted the claude/project-thread-3oq7ug branch October 11, 2026 06:59
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