A story is identified by its key, never by the other device's row number
The two engines both had a notion of "this story, wherever it is read", and kept it in different fields. This app put it in `id`. The iPhone numbers its rows locally in `id` and kept the shared identity in `key`. One name, two meanings, so a story travelling between the two phones came back unrecognisable and the import filed it as a second copy of itself. `key` is the identity now and `id` is written by neither engine. A local handle has no business in a shared shape. `id` is still read as a fallback for files written before this was settled, and only when it is a STRING. A number there is the writing device's row handle: stringifying it produced a confident, meaningless identity, which is the original bug reached by another path. A story with neither falls back to its post time, the same value the iPhone falls back to, so the two still land together. Two tests were pinning the old contract rather than testing it. One asserted the internal id came from the shared `id` when both were present; the other was called `a numeric story id from the iphone becomes a usable name`, which is the bug stated as a requirement. Both rewritten, and the new sibling covering the post-time fallback is what caught the numeric case above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1049c1d118
commit
88397a8bda
@@ -240,10 +240,19 @@ object BackupPayload {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A story in the shared shape.
|
||||
*
|
||||
* `key` is the identity and `id` is NOT written. The two engines had the
|
||||
* same idea and put it in different places: this app keeps the shared
|
||||
* identity straight in `id`, while the iPhone numbers a story locally and
|
||||
* keeps the identity in `key`. One name, two meanings, so a story
|
||||
* travelling between the two came back under a different id and imported
|
||||
* as a second copy of itself.
|
||||
*/
|
||||
private fun storyToShared(s: JSONObject): JSONObject {
|
||||
val out =
|
||||
JSONObject()
|
||||
.put("id", s.opt("id"))
|
||||
.put("author", s.optLong("author"))
|
||||
.put("mine", s.optBoolean("mine"))
|
||||
.put("caption", s.optString("caption"))
|
||||
@@ -252,7 +261,14 @@ object BackupPayload {
|
||||
s.optJSONObject("ref")?.let { out.put("att", attachmentToShared(it, Attachment.STORY)) }
|
||||
s.optString("gid").takeIf { it.isNotEmpty() }?.let { out.put("gid", it) }
|
||||
s.optString("stream").takeIf { it.isNotEmpty() }?.let { out.put("stream", it) }
|
||||
s.optString("key").takeIf { it.isNotEmpty() }?.let { out.put("key", it) }
|
||||
// Always written. This app's `id` IS the identity, so it is the
|
||||
// fallback when an older record carried no separate key, and the post
|
||||
// time is the last resort rather than letting a story travel with no
|
||||
// way to recognise it.
|
||||
out.put(
|
||||
"key",
|
||||
s.optString("key").ifEmpty { s.optString("id") }.ifEmpty { s.optLong("at").toString() },
|
||||
)
|
||||
s.optJSONObject("set")?.let { out.put("set", it) }
|
||||
// The iPhone has no unread mark on a story, so this is ours alone. It
|
||||
// travels anyway: coming back here it is the difference between a strip
|
||||
@@ -429,11 +445,24 @@ object BackupPayload {
|
||||
): JSONObject {
|
||||
val out =
|
||||
JSONObject()
|
||||
// The iPhone numbers its stories and this app names them, so an
|
||||
// id that arrives as a number becomes its own digits rather
|
||||
// than being dropped. `mergeImportedPayload` de-duplicates on
|
||||
// this string, so it only has to be stable, not numeric.
|
||||
.put("id", s.opt("id")?.toString().orEmpty())
|
||||
// The identity, which this app stores as its `id`. `key` is
|
||||
// where it lives in the shared shape; a file written before
|
||||
// that was settled carries it in `id`, and the post time is the
|
||||
// last resort. `mergeImportedPayload` de-duplicates on this
|
||||
// string, so it only has to be stable, not numeric.
|
||||
.put(
|
||||
"id",
|
||||
s
|
||||
.optString("key")
|
||||
// `id` only when it is a STRING. A number there is the
|
||||
// writing device's own row handle and names nothing
|
||||
// here; adopting it is how a story imported as a second
|
||||
// copy of itself. The post time is the last resort, and
|
||||
// it is what the iPhone falls back to as well, so the
|
||||
// two still land on the same value.
|
||||
.ifEmpty { (s.opt("id") as? String).orEmpty() }
|
||||
.ifEmpty { s.optLong("at").toString() },
|
||||
)
|
||||
.put("author", if (s.has("author")) s.optLong("author") else author)
|
||||
.put("mine", s.optBoolean("mine"))
|
||||
.put("caption", s.optString("caption"))
|
||||
@@ -445,7 +474,6 @@ object BackupPayload {
|
||||
s.optJSONObject("att")?.let { out.put("ref", attachmentFromShared(it)) }
|
||||
s.optString("gid").takeIf { it.isNotEmpty() }?.let { out.put("gid", it) }
|
||||
s.optString("stream").takeIf { it.isNotEmpty() }?.let { out.put("stream", it) }
|
||||
s.optString("key").takeIf { it.isNotEmpty() }?.let { out.put("key", it) }
|
||||
s.optJSONObject("set")?.let { out.put("set", it) }
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user