field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit a6b9882741e50ad94bf4283763936451320cb852
parent 676d2335114857d1092ba64d11083e6a9bed6ee3
Author: triesap <tyson@radroots.org>
Date:   Wed,  9 Sep 2026 18:24:48 +0000

today: recover stale cursors without mixed pages

- Reject continuation against missing or changed projection generations with typed stale recovery.
- Preserve visible cards and navigation identity while offering an explicit refresh action.
- Verify repeated stale requests, fresh pagination, scope isolation and ordinary failure retries.
- Rebuild native artifacts and pass the standalone Rust, Swift, Kotlin and API gates.

Diffstat:
MPackage.swift | 1+
MTera.xcodeproj/project.pbxproj | 4++++
MTera/State/TeraTodayPresentation.swift | 24++++++++++++++++++++----
MTera/State/TeraTodayStore.swift | 13+++++++++++--
ATera/Views/TeraTodayPagingStatusView.swift | 18++++++++++++++++++
MTera/Views/TeraTodayStatusView.swift | 2+-
MTera/Views/TeraTodayView.swift | 2+-
MTeraFFI/provenance.json | 40++++++++++++++++++++--------------------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 20++++++++++----------
MTeraFFI/source/aarch64-apple-ios-sim.json | 20++++++++++----------
MTeraFFI/source/aarch64-apple-ios.json | 20++++++++++----------
MTeraTests/TeraErrorRecoveryTests.swift | 4+++-
MTeraTests/TeraScopeBackend.swift | 10+++++-----
ATeraTests/TeraTodayPagingTests.swift | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraTodayPresentationTests.swift | 2++
Mcore/crates/tera_core/src/runtime/product_surface/today.rs | 55+++++++++++++++++++++++++++++++++----------------------
Mcore/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs | 22++++++++++++++++++----
Mcore/crates/tera_ffi/src/error.rs | 8++++++++
Mproject.yml | 1+
Mrelease/provenance.json | 6+++---
21 files changed, 294 insertions(+), 95 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -92,6 +92,7 @@ let package = Package( "Views/TeraSupportingViews.swift", "Views/TeraTodayView.swift", "Views/TeraTodayStatusView.swift", + "Views/TeraTodayPagingStatusView.swift", "Views/RuntimeStatusView.swift", ], resources: [ diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 39B47EE698E9D7CC716A372F /* TeraRuntimeShutdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */; }; 4734A842C366608EC1BD31C5 /* TeraUserMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */; }; 53651EBAF42E8698717CE936 /* TeraScopedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF28A9D711806A07A4A65EA8 /* TeraScopedStoreTests.swift */; }; + 5F7F8C48635A1676BD0A7AF6 /* TeraTodayPagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8120C6BA40C3087C4D11D432 /* TeraTodayPagingTests.swift */; }; 625CB9DF8B80A3F12458B49A /* TeraScopedObservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75B60DBDD8C581D76E6F957 /* TeraScopedObservationTests.swift */; }; 64D49ADFD3FDD54CBA509DC5 /* TeraTodayStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */; }; 671EF50DB4EFBC25AABF26DC /* TeraLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */; }; @@ -111,6 +112,7 @@ 794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayStoreTests.swift; sourceTree = "<group>"; }; 7AF60EF9DCFDC4ECE77AED67 /* TeraProductStartupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraProductStartupTests.swift; sourceTree = "<group>"; }; 7C0836F5D5F71F9A54795C57 /* TeraSessionGenerationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraSessionGenerationTests.swift; sourceTree = "<group>"; }; + 8120C6BA40C3087C4D11D432 /* TeraTodayPagingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayPagingTests.swift; sourceTree = "<group>"; }; 8591841AD9AB9B718A37EB47 /* TeraRuntimeBackpressureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeBackpressureTests.swift; sourceTree = "<group>"; }; 88ED360C84532DA7088BFC6E /* TeraRuntimeClientFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeClientFixtures.swift; sourceTree = "<group>"; }; 91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeShutdownTests.swift; sourceTree = "<group>"; }; @@ -244,6 +246,7 @@ C0636AEDEB50DDDD328374A9 /* TeraSupportingStoreTests.swift */, 53CB51FEF4CE772320B3264C /* TeraTodayCacheFirstTests.swift */, EFECCBF9A84D1D65544C6094 /* TeraTodayContextTests.swift */, + 8120C6BA40C3087C4D11D432 /* TeraTodayPagingTests.swift */, 99BE248D1450D76476255626 /* TeraTodayPresentationTests.swift */, 794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */, 46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */, @@ -496,6 +499,7 @@ 2479571FA99A7962794999C4 /* TeraSupportingStoreTests.swift in Sources */, E98A5C885E3955D4DC4BB1EC /* TeraTodayCacheFirstTests.swift in Sources */, 9995BC15D67E1C9C29B4A7DC /* TeraTodayContextTests.swift in Sources */, + 5F7F8C48635A1676BD0A7AF6 /* TeraTodayPagingTests.swift in Sources */, AA7C3FC1F46B6723A39B1D09 /* TeraTodayPresentationTests.swift in Sources */, 64D49ADFD3FDD54CBA509DC5 /* TeraTodayStoreTests.swift in Sources */, 4734A842C366608EC1BD31C5 /* TeraUserMessagesTests.swift in Sources */, diff --git a/Tera/State/TeraTodayPresentation.swift b/Tera/State/TeraTodayPresentation.swift @@ -22,19 +22,23 @@ enum TeraTodayFreshness: Sendable, Equatable { enum TeraTodayFailure: Sendable, Equatable { case offline(message: String) case failed(message: String) + case staleCursor(message: String) init(_ error: Error) { let message = TeraUserMessages.text(for: error, fallback: .todayUnavailable) - if TeraRuntimeFailure.from(error)?.recovery.disposition == .networkUnavailable { + switch TeraRuntimeFailure.from(error)?.recovery.disposition { + case .staleCursor: + self = .staleCursor(message: message) + case .networkUnavailable: self = .offline(message: message) - } else { + default: self = .failed(message: message) } } var message: String { switch self { - case let .offline(message), let .failed(message): message + case let .offline(message), let .failed(message), let .staleCursor(message): message } } @@ -42,8 +46,20 @@ enum TeraTodayFailure: Sendable, Equatable { switch self { case .offline: "wifi.slash" case .failed: "exclamationmark.triangle" + case .staleCursor: "arrow.clockwise" } } + + var requiresRefresh: Bool { + if case .staleCursor = self { + return true + } + return false + } + + var readStatus: String { + requiresRefresh ? message : "Saved posts could not be read. \(message)" + } } struct TeraTodayPresentation: Sendable, Equatable { @@ -124,7 +140,7 @@ struct TeraTodayPresentation: Sendable, Equatable { messages.append("Refresh failed. \(failure.message)") } if let readFailure { - messages.append("Saved posts could not be read. \(readFailure.message)") + messages.append(readFailure.readStatus) } if let freshnessMessage { messages.append(freshnessMessage) diff --git a/Tera/State/TeraTodayStore.swift b/Tera/State/TeraTodayStore.swift @@ -242,7 +242,7 @@ final class TeraTodayStore: ObservableObject { ) guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return } guard frozenAsOfUnixSeconds == nil || frozenAsOfUnixSeconds == page.asOfUnixSeconds else { - presentation.failRead(.failed(message: "Today changed while loading. Refresh to continue.")) + failPagination(.staleCursor(message: "Today changed while loading. Refresh to continue.")) return } frozenAsOfUnixSeconds = page.asOfUnixSeconds @@ -251,7 +251,7 @@ final class TeraTodayStore: ObservableObject { presentation.acceptPage(count: cards.count) } catch { guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return } - presentation.failRead(TeraTodayFailure(error)) + failPagination(TeraTodayFailure(error)) } } @@ -281,3 +281,12 @@ final class TeraTodayStore: ObservableObject { return cards.filter { identifiers.insert($0.id).inserted } } } + +private extension TeraTodayStore { + func failPagination(_ failure: TeraTodayFailure) { + if failure.requiresRefresh { + nextCursor = nil + } + presentation.failRead(failure) + } +} diff --git a/Tera/Views/TeraTodayPagingStatusView.swift b/Tera/Views/TeraTodayPagingStatusView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct TeraTodayPagingStatusView: View { + @ObservedObject var store: TeraTodayStore + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + TeraTodayStatusView(presentation: store.presentation) + if store.presentation.readFailure?.requiresRefresh == true { + Button("Refresh posts") { Task { await store.reload() } } + .buttonStyle(.bordered) + .frame(minHeight: 44) + .accessibilityHint("Loads the latest posts.") + .accessibilityIdentifier("tera.today.restart") + } + } + } +} diff --git a/Tera/Views/TeraTodayStatusView.swift b/Tera/Views/TeraTodayStatusView.swift @@ -15,7 +15,7 @@ struct TeraTodayStatusView: View { Label("Refresh failed. \(failure.message)", systemImage: failure.systemImage) } if let failure = presentation.readFailure { - Label("Saved posts could not be read. \(failure.message)", systemImage: failure.systemImage) + Label(failure.readStatus, systemImage: failure.systemImage) } if let message = presentation.freshnessMessage { Text(message) diff --git a/Tera/Views/TeraTodayView.swift b/Tera/Views/TeraTodayView.swift @@ -95,7 +95,7 @@ struct TeraTodayView: View { private var feed: some View { List { - TeraTodayStatusView(presentation: store.presentation) + TeraTodayPagingStatusView(store: store) ForEach(store.cards) { card in NavigationLink(value: card) { diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -22,9 +22,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71269296, + "bytes": 71269832, "path": "TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "ad0337396952da878b2e107c1be573b4e0c48d0fdd866e88120c4fb4c701c151" + "sha256": "ba0b9344c7184ba07b135793058034df62fd10b25577c87767b568795dafbae3" }, { "bytes": 70167, @@ -37,9 +37,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71322736, + "bytes": 71323496, "path": "TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "8858bdbba41ed0cb54d52751aa0ba520416c6d01f042b91124e8702e1133fb6f" + "sha256": "b08a85b37b9fa7f1b57379d017693017d5af77f2fa8497a0a63778e82a335b3e" }, { "bytes": 41698, @@ -79,32 +79,32 @@ { "bytes": 19898496, "path": "native/aarch64-apple-darwin/libtera_ffi.dylib", - "sha256": "f0af279892c33ee52459d743929f029089033ded48acb8d64f193fe37da464db" + "sha256": "2dc12119d0f41b981c2d5a2b828fe1f50b22c415f332583198264587d654165c" }, { - "bytes": 71269296, + "bytes": 71269832, "path": "native/aarch64-apple-ios-sim/libtera_ffi.a", - "sha256": "ad0337396952da878b2e107c1be573b4e0c48d0fdd866e88120c4fb4c701c151" + "sha256": "ba0b9344c7184ba07b135793058034df62fd10b25577c87767b568795dafbae3" }, { - "bytes": 71322736, + "bytes": 71323496, "path": "native/aarch64-apple-ios/libtera_ffi.a", - "sha256": "8858bdbba41ed0cb54d52751aa0ba520416c6d01f042b91124e8702e1133fb6f" + "sha256": "b08a85b37b9fa7f1b57379d017693017d5af77f2fa8497a0a63778e82a335b3e" }, { "bytes": 62605, "path": "source/aarch64-apple-darwin.json", - "sha256": "51a771c0fa3f18124fe4380374155ed66e01f37becf03c911c1e6fa34754aafd" + "sha256": "d22e54a5529c5fb0c57b0cc979381ccf2a20e43e8ec34202db4b14b9dd41f235" }, { "bytes": 62449, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "6705cc1ddf4b64331c113f81c8f3bef337fac04213267d543056f3432687acfa" + "sha256": "bbf54037302cb32dbe3dddb18b04438c7a45359b3149d9b93874323ff5b60330" }, { "bytes": 62445, "path": "source/aarch64-apple-ios.json", - "sha256": "be92fa3025d4ee18789b661022bd02227f71e92b70fce0f70918b6b16cf55cb1" + "sha256": "ab1c2afee7e78d10e5e94bc321ed12238d814af92147ff303a6a1cfdf44440bd" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "15bab536eccf803b40d32dbb228e64282272709f" + "tree": "4cf86c25de8e96b9636e00cb4d2d59b4be732688" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -139,9 +139,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71269296, + "bytes": 71269832, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "ad0337396952da878b2e107c1be573b4e0c48d0fdd866e88120c4fb4c701c151" + "sha256": "ba0b9344c7184ba07b135793058034df62fd10b25577c87767b568795dafbae3" }, { "bytes": 70167, @@ -154,9 +154,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71322736, + "bytes": 71323496, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "8858bdbba41ed0cb54d52751aa0ba520416c6d01f042b91124e8702e1133fb6f" + "sha256": "b08a85b37b9fa7f1b57379d017693017d5af77f2fa8497a0a63778e82a335b3e" }, { "bytes": 492997, @@ -176,17 +176,17 @@ { "bytes": 62605, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "51a771c0fa3f18124fe4380374155ed66e01f37becf03c911c1e6fa34754aafd" + "sha256": "d22e54a5529c5fb0c57b0cc979381ccf2a20e43e8ec34202db4b14b9dd41f235" }, { "bytes": 62449, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "6705cc1ddf4b64331c113f81c8f3bef337fac04213267d543056f3432687acfa" + "sha256": "bbf54037302cb32dbe3dddb18b04438c7a45359b3149d9b93874323ff5b60330" }, { "bytes": 62445, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "be92fa3025d4ee18789b661022bd02227f71e92b70fce0f70918b6b16cf55cb1" + "sha256": "ab1c2afee7e78d10e5e94bc321ed12238d814af92147ff303a6a1cfdf44440bd" } ], "schema": "tera.installed-native-artifacts.v1" diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock @@ -1,7 +1,7 @@ schema = "tera.installed-source.v1" repository = "https://github.com/radrootslabs/tera" -source_tree = "15bab536eccf803b40d32dbb228e64282272709f" -manifest_sha256 = "629c3620deddf0b3b80b01b1ae13d508567ae09872efd5fb39975bc02b7eb682" +source_tree = "4cf86c25de8e96b9636e00cb4d2d59b4be732688" +manifest_sha256 = "68a7d328ad834e62bc05f859eb2bc527d4a78a8f38cfab7e72e1feb4eb6c839c" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -721,10 +721,10 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 140369, - "git_blob": "5b3c7fdf795eb3fa203a53a695cf7468af724c7e", + "bytes": 140595, + "git_blob": "a006f757c82a3160d140afb99d2c4615738010ef", "mode": "100644", - "sha256": "349d6f786d8b7759c2c8e7231d36fc042d9bb937d064d9e7ed4b2a4242fc2708" + "sha256": "00ec4a99b1ee3acd22460540a52630c9f6d05715372d1957268303b9bb810f76" }, "core/crates/tera_core/src/runtime/product_surface/today_paging_scope.rs": { "bytes": 1703, @@ -733,10 +733,10 @@ "sha256": "e9ea1a0ee6c52031895184cd0a2b3c526318fd521ab2bd6b1b08ef53f3c9ff66" }, "core/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs": { - "bytes": 13698, - "git_blob": "e1640d8367278dc45dd85fcc598f991ae7cb16ca", + "bytes": 14171, + "git_blob": "0656c65da966fa6f46a634a91201236d8469cc2e", "mode": "100644", - "sha256": "ee40a9f39d46c0780dff2d64d0d88c7a48a9ce81d68e636063c2d02ec5501357" + "sha256": "3d286106402df349e1acd61b1a962dca97837301fc265d624d4bc72174589376" }, "core/crates/tera_core/src/runtime/sdk.rs": { "bytes": 20647, @@ -787,10 +787,10 @@ "sha256": "b1460005306c2547ddcd419237f5e0fd16ba5208bdec93d313756d5a153d3499" }, "core/crates/tera_ffi/src/error.rs": { - "bytes": 19893, - "git_blob": "2313f32ede9e60334e68f137f86249810d349b19", + "bytes": 20215, + "git_blob": "74cd2baef3245474d56ce8033281b6be23e2b595", "mode": "100644", - "sha256": "f02e95e9eb45a5d2775e9447e410d44d8e6111192772fdc9f4d4395594228f82" + "sha256": "2a2b0ee85005a41d30a331ef2a02f9f8ded1ee0480493307fced379452d963cb" }, "core/crates/tera_ffi/src/invalidation.rs": { "bytes": 6914, @@ -1358,6 +1358,6 @@ } }, "policy": "staged_inputs", - "tree": "15bab536eccf803b40d32dbb228e64282272709f" + "tree": "4cf86c25de8e96b9636e00cb4d2d59b4be732688" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -717,10 +717,10 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 140369, - "git_blob": "5b3c7fdf795eb3fa203a53a695cf7468af724c7e", + "bytes": 140595, + "git_blob": "a006f757c82a3160d140afb99d2c4615738010ef", "mode": "100644", - "sha256": "349d6f786d8b7759c2c8e7231d36fc042d9bb937d064d9e7ed4b2a4242fc2708" + "sha256": "00ec4a99b1ee3acd22460540a52630c9f6d05715372d1957268303b9bb810f76" }, "core/crates/tera_core/src/runtime/product_surface/today_paging_scope.rs": { "bytes": 1703, @@ -729,10 +729,10 @@ "sha256": "e9ea1a0ee6c52031895184cd0a2b3c526318fd521ab2bd6b1b08ef53f3c9ff66" }, "core/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs": { - "bytes": 13698, - "git_blob": "e1640d8367278dc45dd85fcc598f991ae7cb16ca", + "bytes": 14171, + "git_blob": "0656c65da966fa6f46a634a91201236d8469cc2e", "mode": "100644", - "sha256": "ee40a9f39d46c0780dff2d64d0d88c7a48a9ce81d68e636063c2d02ec5501357" + "sha256": "3d286106402df349e1acd61b1a962dca97837301fc265d624d4bc72174589376" }, "core/crates/tera_core/src/runtime/sdk.rs": { "bytes": 20647, @@ -783,10 +783,10 @@ "sha256": "b1460005306c2547ddcd419237f5e0fd16ba5208bdec93d313756d5a153d3499" }, "core/crates/tera_ffi/src/error.rs": { - "bytes": 19893, - "git_blob": "2313f32ede9e60334e68f137f86249810d349b19", + "bytes": 20215, + "git_blob": "74cd2baef3245474d56ce8033281b6be23e2b595", "mode": "100644", - "sha256": "f02e95e9eb45a5d2775e9447e410d44d8e6111192772fdc9f4d4395594228f82" + "sha256": "2a2b0ee85005a41d30a331ef2a02f9f8ded1ee0480493307fced379452d963cb" }, "core/crates/tera_ffi/src/invalidation.rs": { "bytes": 6914, @@ -1354,6 +1354,6 @@ } }, "policy": "staged_inputs", - "tree": "15bab536eccf803b40d32dbb228e64282272709f" + "tree": "4cf86c25de8e96b9636e00cb4d2d59b4be732688" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -717,10 +717,10 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 140369, - "git_blob": "5b3c7fdf795eb3fa203a53a695cf7468af724c7e", + "bytes": 140595, + "git_blob": "a006f757c82a3160d140afb99d2c4615738010ef", "mode": "100644", - "sha256": "349d6f786d8b7759c2c8e7231d36fc042d9bb937d064d9e7ed4b2a4242fc2708" + "sha256": "00ec4a99b1ee3acd22460540a52630c9f6d05715372d1957268303b9bb810f76" }, "core/crates/tera_core/src/runtime/product_surface/today_paging_scope.rs": { "bytes": 1703, @@ -729,10 +729,10 @@ "sha256": "e9ea1a0ee6c52031895184cd0a2b3c526318fd521ab2bd6b1b08ef53f3c9ff66" }, "core/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs": { - "bytes": 13698, - "git_blob": "e1640d8367278dc45dd85fcc598f991ae7cb16ca", + "bytes": 14171, + "git_blob": "0656c65da966fa6f46a634a91201236d8469cc2e", "mode": "100644", - "sha256": "ee40a9f39d46c0780dff2d64d0d88c7a48a9ce81d68e636063c2d02ec5501357" + "sha256": "3d286106402df349e1acd61b1a962dca97837301fc265d624d4bc72174589376" }, "core/crates/tera_core/src/runtime/sdk.rs": { "bytes": 20647, @@ -783,10 +783,10 @@ "sha256": "b1460005306c2547ddcd419237f5e0fd16ba5208bdec93d313756d5a153d3499" }, "core/crates/tera_ffi/src/error.rs": { - "bytes": 19893, - "git_blob": "2313f32ede9e60334e68f137f86249810d349b19", + "bytes": 20215, + "git_blob": "74cd2baef3245474d56ce8033281b6be23e2b595", "mode": "100644", - "sha256": "f02e95e9eb45a5d2775e9447e410d44d8e6111192772fdc9f4d4395594228f82" + "sha256": "2a2b0ee85005a41d30a331ef2a02f9f8ded1ee0480493307fced379452d963cb" }, "core/crates/tera_ffi/src/invalidation.rs": { "bytes": 6914, @@ -1354,6 +1354,6 @@ } }, "policy": "staged_inputs", - "tree": "15bab536eccf803b40d32dbb228e64282272709f" + "tree": "4cf86c25de8e96b9636e00cb4d2d59b4be732688" } } diff --git a/TeraTests/TeraErrorRecoveryTests.swift b/TeraTests/TeraErrorRecoveryTests.swift @@ -29,7 +29,9 @@ final class TeraErrorRecoveryTests: XCTestCase { XCTAssertEqual(TeraUserMessages.key(for: wrapped, fallback: .todayUnavailable), key, code) XCTAssertEqual( TeraTodayFailure(wrapped), - .failed(message: TeraUserMessages.text(key)), code + code == "today_cursor_invalid" + ? .staleCursor(message: TeraUserMessages.text(key)) + : .failed(message: TeraUserMessages.text(key)), code ) XCTAssertEqual(TeraMediaStore.failureState(wrapped), .failed, code) } diff --git a/TeraTests/TeraScopeBackend.swift b/TeraTests/TeraScopeBackend.swift @@ -5,7 +5,7 @@ actor TeraScopeBackend: TeraRuntimeBackend { enum Call: Hashable { case snapshot, drafts, save, probe, page, refresh, search, me, subscribe, media, invalidate } struct Pending { let pause: ResourceTestPause - let fails: Bool + let failure: TeraRuntimeFailure? } private(set) var value = TeraScopeFixtures.snapshot() @@ -24,9 +24,9 @@ actor TeraScopeBackend: TeraRuntimeBackend { media = try TeraScopeFixtures.artifact("a") } - func pause(_ call: Call, fails: Bool = false) -> ResourceTestPause { + func pause(_ call: Call, fails: Bool = false, failure: TeraRuntimeFailure? = nil) -> ResourceTestPause { let pause = ResourceTestPause() - pending[call, default: []].append(Pending(pause: pause, fails: fails)) + pending[call, default: []].append(Pending(pause: pause, failure: failure ?? (fails ? TeraScopeFixtures.failure() : nil))) return pause } @@ -56,8 +56,8 @@ actor TeraScopeBackend: TeraRuntimeBackend { let next = queue.removeFirst() pending[call] = queue await next.pause.wait() - if next.fails { - throw TeraScopeFixtures.failure() + if let failure = next.failure { + throw failure } } diff --git a/TeraTests/TeraTodayPagingTests.swift b/TeraTests/TeraTodayPagingTests.swift @@ -0,0 +1,113 @@ +@testable import TeraApp +import XCTest + +@MainActor +final class TeraTodayPagingTests: XCTestCase { + func testStalePagePreservesCardsAndIdentityUntilExplicitRefresh() async throws { + let backend = try TeraScopeBackend() + let (client, store) = try await makeStore(backend) + let cards = store.cards + let generation = store.scopeGeneration + let pause = await backend.pause(.page, failure: stale()) + let loading = Task { await store.loadNextPage() } + await pause.entered.wait() + XCTAssertEqual(store.cards, cards) + await pause.resume.open() + await loading.value + XCTAssertEqual(store.cards, cards) + XCTAssertEqual(store.scopeGeneration, generation) + XCTAssertEqual(store.presentation.content, .available) + XCTAssertEqual(store.presentation.refresh, .completed) + XCTAssertTrue(store.presentation.readFailure?.requiresRefresh == true) + XCTAssertFalse(store.isLoadingNextPage) + XCTAssertFalse(store.canLoadNextPage) + let before = await backend.counts + for _ in 0 ..< 3 { + await store.loadNextPage() + } + let after = await backend.counts + XCTAssertEqual(before, after) + await assertExplicitRefresh(backend, store: store, retained: cards) + _ = try await client.stop() + } + + func testMismatchedAsOfRefusesAppendAndStopsTheOldCursor() async throws { + let backend = try TeraScopeBackend() + let (client, store) = try await makeStore(backend) + await backend.setPage(TeraTodayPage(asOfUnixSeconds: 2, items: [TeraScopeFixtures.card("wrong")], nextCursor: "wrong"), cursor: "next") + let cards = store.cards + let generation = store.scopeGeneration + await store.loadNextPage() + XCTAssertEqual(store.cards, cards) + XCTAssertEqual(store.scopeGeneration, generation) + XCTAssertTrue(store.presentation.readFailure?.requiresRefresh == true) + XCTAssertFalse(store.canLoadNextPage) + XCTAssertFalse(store.isLoadingNextPage) + _ = try await client.stop() + } + + func testLateStaleFailureCannotDisableTheNewContextCursor() async throws { + let backend = try TeraScopeBackend() + let (client, store) = try await makeStore(backend) + let pause = await backend.pause(.page, failure: stale()) + let old = Task { await store.loadNextPage() } + await pause.entered.wait() + store.configure(snapshot: TeraScopeFixtures.snapshot(account: "b", relay: "second")) + await backend.setPage(page(["new"], next: "new-next")) + await store.reload(refreshProjection: false) + await pause.resume.open() + await old.value + XCTAssertEqual(store.cards.map(\.id), ["new"]) + XCTAssertTrue(store.canLoadNextPage) + XCTAssertNil(store.presentation.readFailure) + _ = try await client.stop() + } + + func testStalenessUsesTypedRecoveryAndKeepsAccessibleGuidance() { + let failure = TeraTodayFailure(TeraRuntimeClientError.today(stale())) + XCTAssertTrue(failure.requiresRefresh) + XCTAssertEqual(failure.readStatus, TeraUserMessages.text(.todayChanged)) + let storage = TeraRuntimeFailure.local(operation: "test", code: "today_storage_failed", safeMessage: "stale cursor") + XCTAssertFalse(TeraTodayFailure(storage).requiresRefresh) + var presentation = TeraTodayPresentation() + presentation.acceptPage(count: 2) + presentation.failRead(failure) + XCTAssertEqual(presentation.content, .available) + XCTAssertTrue(presentation.accessibilityStatus.contains(failure.message)) + XCTAssertFalse(presentation.accessibilityStatus.contains("Saved posts could not be read.")) + } + + private func assertExplicitRefresh(_ backend: TeraScopeBackend, store: TeraTodayStore, retained: [TeraTodayCard]) async { + await backend.setPage(page(["one", "new"], next: "fresh-next")) + await backend.setPage(page(["last"]), cursor: "fresh-next") + let pause = await backend.pause(.page) + let refresh = Task { await store.reload(refreshProjection: false) } + await pause.entered.wait() + XCTAssertEqual(store.cards, retained) + await pause.resume.open() + await refresh.value + XCTAssertEqual(store.cards.map(\.id), ["one", "new"]) + XCTAssertNil(store.presentation.readFailure) + XCTAssertTrue(store.canLoadNextPage) + await store.loadNextPage() + XCTAssertEqual(store.cards.map(\.id), ["one", "new", "last"]) + XCTAssertFalse(store.canLoadNextPage) + } + + private func makeStore(_ backend: TeraScopeBackend) async throws -> (TeraRuntimeClient, TeraTodayStore) { + await backend.setPage(page(["one", "two"], next: "next")) + let client = try await TeraScopeFixtures.client(backend) + let store = TeraTodayStore(runtimeClient: client, clock: .fixed(unixSeconds: 1)) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + await store.reload() + return (client, store) + } + + private func page(_ ids: [String], next: String? = nil) -> TeraTodayPage { + TeraTodayPage(asOfUnixSeconds: 1, items: ids.map(TeraScopeFixtures.card), nextCursor: next) + } + + private func stale() -> TeraRuntimeFailure { + .local(operation: "test", code: "today_cursor_invalid", safeMessage: "Controlled stale page.") + } +} diff --git a/TeraTests/TeraTodayPresentationTests.swift b/TeraTests/TeraTodayPresentationTests.swift @@ -118,6 +118,8 @@ final class TeraTodayPresentationTests: XCTestCase { XCTAssertEqual(store.presentation.refresh, .completed) XCTAssertNotNil(store.presentation.readFailure) XCTAssertEqual(store.presentation.freshness, .unconfirmed) + XCTAssertTrue(store.canLoadNextPage) + XCTAssertFalse(store.presentation.readFailure?.requiresRefresh == true) await store.loadNextPage() XCTAssertEqual(store.cards.map(\.id), ["one", "two"]) XCTAssertNil(store.presentation.readFailure) diff --git a/core/crates/tera_core/src/runtime/product_surface/today.rs b/core/crates/tera_core/src/runtime/product_surface/today.rs @@ -465,10 +465,16 @@ impl TeraRuntime { let position = TodayCursor::decode(cursor, &scope)?; let mut snapshot = load_snapshot(storage, projection_id, algorithm_generation, &scope) .await? - .ok_or(TodayError::SnapshotMissing)?; + .ok_or(CursorError::Stale)?; let current = load_state(storage, context, algorithm_generation) .await? - .ok_or(TodayError::ProjectionMissing)?; + .ok_or(CursorError::Stale)?; + if current.store_generation != scope.store_generation + || current.query_scope != Some(scope.query_scope) + || current.content_generation != scope.projection_generation + { + return Err(CursorError::Stale.into()); + } sanitize_snapshot_media(&mut snapshot, &current.media_cache); (scope, snapshot, Some(position.rank)) } else { @@ -2597,7 +2603,7 @@ mod tests { } #[tokio::test] - async fn equal_timestamp_pages_are_complete_and_remain_frozen_across_ingest() { + async fn changed_projection_rejects_repeated_old_cursor_and_refreshes_completely() { let runtime = TeraRuntime::test_memory().expect("runtime"); let context = context(None, 1); for content in ["alpha", "bravo", "charlie"] { @@ -2624,30 +2630,35 @@ mod tests { ) .await; - let mut ids = first - .items - .iter() - .map(|card| card.card.card_id.to_hex()) - .collect::<Vec<_>>(); - let mut cursor = Some(frozen_cursor); - while let Some(value) = cursor { - let page = runtime - .phase1_today_page(&context, TodayPageRequest::after(1, value)) - .await - .expect("continued frozen page"); - ids.extend(page.items.iter().map(|card| card.card.card_id.to_hex())); - cursor = page.next_cursor; + for _ in 0..3 { + assert!(matches!( + runtime + .phase1_today_page(&context, TodayPageRequest::after(1, frozen_cursor.clone())) + .await, + Err(TodayError::Cursor(CursorError::Stale)) + )); } - ids.sort(); - assert_eq!(ids.len(), 3, "each frozen item appears exactly once"); - ids.dedup(); - assert_eq!(ids.len(), 3, "frozen snapshot has no loss or duplicates"); let current = runtime - .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_201)) + .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_200)) .await .expect("current page"); assert_eq!(current.items.len(), 4); + assert_eq!( + current + .items + .iter() + .map(|item| item.card.card_id) + .collect::<std::collections::BTreeSet<_>>() + .len(), + 4 + ); + assert!( + current + .items + .iter() + .any(|item| item.card.content == "delta") + ); } #[tokio::test] @@ -3678,7 +3689,7 @@ mod tests { runtime .phase1_today_page(&context, TodayPageRequest::after(1, cursor_for(missing))) .await, - Err(TodayError::SnapshotMissing) + Err(TodayError::Cursor(CursorError::Stale)) )); let snapshot = diff --git a/core/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs b/core/crates/tera_core/src/runtime/product_surface/today_scope_tests.rs @@ -137,13 +137,27 @@ async fn same_identity_and_generation_do_not_authorize_another_query() { .collect(); assert_eq!(second_ids, expected_ids); assert_ne!(other.next_cursor.as_ref(), Some(&cursor)); + let scope = TodayCursor::scope(&cursor).unwrap(); + let position = TodayCursor::decode(&cursor, &scope).unwrap(); + let retained = load_snapshot( + runtime.client.storage().unwrap(), + projection_id().unwrap(), + projection_generation().unwrap(), + &scope, + ) + .await + .unwrap() + .unwrap(); assert_eq!( - runtime - .phase1_today_page(&first, TodayPageRequest::after(1, cursor.clone())) - .await - .unwrap(), + page_from_snapshot(retained, scope, Some(position.rank), 1).unwrap(), original ); + assert!(matches!( + runtime + .phase1_today_page(&first, TodayPageRequest::after(1, cursor.clone())) + .await, + Err(TodayError::Cursor(CursorError::Stale)) + )); for changed in [ LocalNetwork { label: "Changed".into(), diff --git a/core/crates/tera_ffi/src/error.rs b/core/crates/tera_ffi/src/error.rs @@ -445,6 +445,14 @@ mod tests { ] { let error = TeraAppError::from(TodayError::Cursor(cursor)); assert_eq!(error.report().code, "today_cursor_invalid"); + let recovery = crate::recovery::classify_error_recovery( + error.report().schema_version, + error.report().code.clone(), + ); + assert_eq!( + recovery.disposition, + crate::recovery::FfiRecoveryDisposition::StaleCursor + ); } for media in [ Phase1InboundMediaError::InvalidReference, diff --git a/project.yml b/project.yml @@ -64,6 +64,7 @@ targets: - path: TeraTests/TeraTodayPresentationTests.swift - path: TeraTests/TeraTodayCacheFirstTests.swift - path: TeraTests/TeraTodayContextTests.swift + - path: TeraTests/TeraTodayPagingTests.swift - path: TeraTests/TeraProductStartupTests.swift - path: TeraTests/TeraAddSchemaFixtures.swift - path: TeraTests/TeraScopeFixtures.swift diff --git a/release/provenance.json b/release/provenance.json @@ -2,11 +2,11 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "ab87eb6e3d4512acc3986120c38988f8e7559ac1781de76d9808e0bae338d1de", - "ffi_provenance_sha256": "629c3620deddf0b3b80b01b1ae13d508567ae09872efd5fb39975bc02b7eb682", + "ffi_provenance_sha256": "68a7d328ad834e62bc05f859eb2bc527d4a78a8f38cfab7e72e1feb4eb6c839c", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "6b5ce897d5273290febc8b831663e12dea759cb0084f182ba7949e1c593fc410", - "xcode_project_sha256": "5738745e0ad43c494bb7127ddb0a56479cccaae839634b4c0bb9b4c278efe386" + "xcode_project_sha256": "09a5e54a257aa67ebadeae5eb256790426e6dbb9bf0fa3a1a854ff1c2f455ec9" }, "disposition": "unsigned", "platforms": [ @@ -22,7 +22,7 @@ "lib_revision": "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "94ae067a374726cdaf6b4ca0a5e44663c57fcdc5334060c5ffef5e79cfbf04c0", - "tera_ffi_source_tree": "15bab536eccf803b40d32dbb228e64282272709f", + "tera_ffi_source_tree": "4cf86c25de8e96b9636e00cb4d2d59b4be732688", "xcode_package_lock_sha256": "c7f41934ea25f7a287bdc4f3a6ecabbf09a3a0bdd0f5a3e58183f355ca814096" }, "version": "0.1.0-alpha"