Disclosure: This article was drafted by AI from Pianory’s source code. I’m Pianory’s maker. The implementation details describe the code inspected; this article does not report an independent production test.
A signup flow is often described as “turning a guest into an account.” That phrasing hides a data boundary. Guest work may exist only in one browser’s IndexedDB, while account data has a server identity and a sync queue. Treating sign-in as a switch can mix two people’s data, or make local work appear to vanish.
Keep two identities
In Pianory’s current implementation, guest and account records share a local IndexedDB database, but records are keyed by a namespace. The guest namespace is literally guest; account namespaces include the authenticated user ID. The store uses that namespace when reading and writing songs, lists, sessions, settings, and learning progress. Guest writes stay local; account writes can create queued sync operations.
Signing in does not automatically turn guest records into account records. Instead, an import screen reads both namespaces and lets the person choose songs and practice lists. It has separate controls for history attached to selected songs and history whose song is deleted, missing, or absent. Unchecked items remain in guest mode on that device. A list can be copied only when every song it references will be available in the account.
This is a small transfer protocol, rather than just a confirmation checkbox.
Freeze the plan before sending
Build a selection plan from the account’s existing contents, including list dependencies. Enforce destination quotas on the server.
The importer maps selected guest IDs to target IDs, rewrites list references, and maps session history to selected or already-account-owned songs. Its journal is scoped to the account ID. Before sending anything, it saves the request plan, including its import ID, batches, and cleanup set. If the browser closes mid-import, a retry uses that frozen plan instead of creating a different one.
Verify each retry
Each operation ID is derived from the import ID, entity type, and target record ID. The server endpoint requires an authenticated user, applies the account’s storage limits, and checks that referenced songs belong to that account.
The client also verifies the response. A successful HTTP status alone is insufficient: the returned results must confirm the exact expected operations, with no missing or duplicate result IDs.
A simplified ordering is:
persist frozen import plan
for each unverified batch:
send batch with stable operation IDs
verify every expected result
persist batch receipt
pull account data
clean up selected guest records
clear pending journal
Stable IDs let the server recognize an accepted operation as the same import when retried, rather than a second copy.
Delay cleanup until the destination is visible
After every batch is confirmed, the client pulls account changes. Only then does it remove imported goals and lists, and imported songs that are no longer referenced by a guest list. Practice history is intentionally retained as a device backup.
If a request is incomplete or the account pull fails, cleanup is not attempted. If cleanup cannot finish, the journal remains available for recovery.
Review the boundary, not just the happy path
Useful scenarios include closing the browser after the server accepts a batch but before the client saves its receipt; signing into a different account; receiving a partial result set; and losing connectivity before the account refresh. These are review cases, not test results claimed by this article.
The pattern is useful for apps that begin with local or anonymous use: give guest and account data distinct identities, ask what should cross the boundary, preserve relationships, and make the operation resumable before deleting source data.
“Keep this device’s data” and “copy selected data into my account” are meaningfully different choices.
I’m applying this pattern while building Pianory, a browser-based piano learning and practice app. How does your app make that local-to-account boundary visible to the user?
Top comments (0)