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 97b38cecda3a43ef5f5f73a7e21cef1811c0886a
parent d35da2725d134faef0cf18a616a622686ab02608
Author: triesap <tyson@radroots.org>
Date:   Wed,  9 Sep 2026 16:06:44 +0000

today: render local data before network refresh

- Read authorized cached or empty Today content before starting network work.
- Materialize missing Rust projections from admitted local data and preserve errors.
- Reject stale refreshed pages and pagination while retaining caller cancellation.
- Verify local query, stalled refresh and offline behavior across native consumers.

Diffstat:
MTera.xcodeproj/project.pbxproj | 4++++
MTera/State/TeraTodayPresentation.swift | 10++++++----
MTera/State/TeraTodayStore.swift | 22++++++++++++++++------
MTeraFFI/provenance.json | 40++++++++++++++++++++--------------------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 8++++----
MTeraFFI/source/aarch64-apple-ios-sim.json | 8++++----
MTeraFFI/source/aarch64-apple-ios.json | 8++++----
ATeraTests/TeraTodayCacheFirstTests.swift | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraTodayPresentationTests.swift | 12+++++++++---
MTeraTests/TeraTodayStoreTests.swift | 25++++++++++++++++++-------
Mcore/crates/tera_core/src/runtime/product_surface/today.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mproject.yml | 1+
Mrelease/provenance.json | 6+++---
14 files changed, 347 insertions(+), 60 deletions(-)

diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ D918A664D9A4B9859A1A6503 /* TeraErrorRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A3358AFC23C567DB9CB02F /* TeraErrorRecoveryTests.swift */; }; DF97F408D7D32F72C5AF5939 /* TeraRootShellUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5238B5F236C392C82CEF9838 /* TeraRootShellUITests.swift */; }; E508A7061082B091B733D62B /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67ACCE25979BA86C1EC5BFD6 /* App.swift */; }; + E98A5C885E3955D4DC4BB1EC /* TeraTodayCacheFirstTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CB51FEF4CE772320B3264C /* TeraTodayCacheFirstTests.swift */; }; EF7293C553BF1DB45EEEBD49 /* local-social-personas.v1.json in Resources */ = {isa = PBXBuildFile; fileRef = 27D9D40699A01FFB02F30B11 /* local-social-personas.v1.json */; }; F4AF91E71B691FE30C191852 /* TeraAddObservationStartupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44A4460928B20A21A94BECCE /* TeraAddObservationStartupTests.swift */; }; F8C87782857DC930D47E6A90 /* TeraRuntimeResourceTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */; }; @@ -95,6 +96,7 @@ 44A4460928B20A21A94BECCE /* TeraAddObservationStartupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddObservationStartupTests.swift; sourceTree = "<group>"; }; 46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraUserMessagesTests.swift; sourceTree = "<group>"; }; 5238B5F236C392C82CEF9838 /* TeraRootShellUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRootShellUITests.swift; sourceTree = "<group>"; }; + 53CB51FEF4CE772320B3264C /* TeraTodayCacheFirstTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayCacheFirstTests.swift; sourceTree = "<group>"; }; 57CA176D0A7566A4ADD2CEA4 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; 58E711687008866790042792 /* TeraBindingPortabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraBindingPortabilityTests.swift; sourceTree = "<group>"; }; 61B6F0B16BFD8F046E9C163F /* TeraMediaStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraMediaStoreTests.swift; sourceTree = "<group>"; }; @@ -235,6 +237,7 @@ BAD45A3134A16704CBE4B1A5 /* TeraStateTestFixtures.swift */, BD39C5F3460EEF38B98D78B4 /* TeraStoreRecoveryTests.swift */, C0636AEDEB50DDDD328374A9 /* TeraSupportingStoreTests.swift */, + 53CB51FEF4CE772320B3264C /* TeraTodayCacheFirstTests.swift */, 99BE248D1450D76476255626 /* TeraTodayPresentationTests.swift */, 794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */, 46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */, @@ -484,6 +487,7 @@ B1B83B8673660E0EC109F74C /* TeraStateTestFixtures.swift in Sources */, 30EDD3EB0C09D864FC1DD3D4 /* TeraStoreRecoveryTests.swift in Sources */, 2479571FA99A7962794999C4 /* TeraSupportingStoreTests.swift in Sources */, + E98A5C885E3955D4DC4BB1EC /* TeraTodayCacheFirstTests.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 @@ -53,17 +53,19 @@ struct TeraTodayPresentation: Sendable, Equatable { private(set) var readFailure: TeraTodayFailure? private(set) var isReading = false - mutating func beginReload(refreshProjection: Bool) { + mutating func beginReload() { readFailure = nil isReading = false freshness = .unconfirmed - if refreshProjection { - refresh = .refreshing - } else if refresh == .refreshing { + if refresh == .refreshing { refresh = .idle } } + mutating func beginRefresh() { + refresh = .refreshing + } + mutating func refreshCompleted() { refresh = .completed } diff --git a/Tera/State/TeraTodayStore.swift b/Tera/State/TeraTodayStore.swift @@ -134,6 +134,7 @@ final class TeraTodayStore: ObservableObject { refreshProjection: Bool = true, update: TeraTodayProjectionUpdate = .incremental ) async { + guard !Task.isCancelled else { return } guard let context = selectedContext else { cards = [] presentation = TeraTodayPresentation() @@ -142,7 +143,7 @@ final class TeraTodayStore: ObservableObject { } requestGeneration = requestGeneration.invalidated() - let generation = requestGeneration + var generation = requestGeneration defer { if generation == requestGeneration { presentation.stop() @@ -151,18 +152,27 @@ final class TeraTodayStore: ObservableObject { frozenAsOfUnixSeconds = nil nextCursor = nil isLoadingNextPage = false - presentation.beginReload(refreshProjection: refreshProjection) - var receipt: TeraTodayRefreshReceipt? - if refreshProjection { - receipt = await refresh(context: context, update: update, generation: generation) - } + presentation.beginReload() + guard generation.isActive else { return } + await readFirstPage(context: context, generation: generation, receipt: nil) + guard refreshProjection, generation == requestGeneration, !Task.isCancelled else { return } + let receipt = await refresh(context: context, update: update, generation: generation) guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return } + // Invalidate pagination from the cached page before reading the updated + // projection, even when the new page has the same as-of timestamp. + requestGeneration = requestGeneration.invalidated() + generation = requestGeneration + frozenAsOfUnixSeconds = nil + nextCursor = nil + isLoadingNextPage = false + guard generation.isActive else { return } await readFirstPage(context: context, generation: generation, receipt: receipt) } private func refresh( context: TeraLocalNetwork, update: TeraTodayProjectionUpdate, generation: TeraSessionGeneration ) async -> TeraTodayRefreshReceipt? { + presentation.beginRefresh() do { let receipt = try await runtimeClient.refreshToday( context: context, nowUnixSeconds: clock.unixSeconds(), update: update diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -22,9 +22,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71246424, + "bytes": 71247176, "path": "TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "975b87ff13ed9d3d17fa77f0b48b47450a4e1d3e68b1ba6a53907540aa8a9b92" + "sha256": "9f83b0d9f50c3b59736ae843ffa503bd678654b571b7ff4ad6109ffd66af2e87" }, { "bytes": 70167, @@ -37,9 +37,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71298224, + "bytes": 71299416, "path": "TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "ad723743c7dec49a87559d72deb6f1d976718693e88cc324291c4d55ce795c5b" + "sha256": "b29a77aba06a05e8901628bf83a8e02abfee0991dca60f1306e2c6a47e1f1bdb" }, { "bytes": 41698, @@ -79,32 +79,32 @@ { "bytes": 19860544, "path": "native/aarch64-apple-darwin/libtera_ffi.dylib", - "sha256": "5df79216403c6b413f5a7682868e3cbb0dbd1fda52f886b8f8123aa97f929a4b" + "sha256": "b2d1aefdaaa7d9419389c21ab8156ff869d9027bd0ebd5d4ad1cea8566182344" }, { - "bytes": 71246424, + "bytes": 71247176, "path": "native/aarch64-apple-ios-sim/libtera_ffi.a", - "sha256": "975b87ff13ed9d3d17fa77f0b48b47450a4e1d3e68b1ba6a53907540aa8a9b92" + "sha256": "9f83b0d9f50c3b59736ae843ffa503bd678654b571b7ff4ad6109ffd66af2e87" }, { - "bytes": 71298224, + "bytes": 71299416, "path": "native/aarch64-apple-ios/libtera_ffi.a", - "sha256": "ad723743c7dec49a87559d72deb6f1d976718693e88cc324291c4d55ce795c5b" + "sha256": "b29a77aba06a05e8901628bf83a8e02abfee0991dca60f1306e2c6a47e1f1bdb" }, { "bytes": 61732, "path": "source/aarch64-apple-darwin.json", - "sha256": "60f5dd2de192cae21c908a57bab5f020b07303bfd967a7862fdd94bb3356237d" + "sha256": "d345cf081f8e930146fee681e2850ffef638cde01f44e892fe5d29e187f6f511" }, { "bytes": 61576, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "3f1f0c4b9cd588994ff448ccf2d9114e026328ac16f0662972fcb09859a1896d" + "sha256": "d69260103114a7ce7775892be8a66606e7107335f25b2595aae514ad52674cbe" }, { "bytes": 61572, "path": "source/aarch64-apple-ios.json", - "sha256": "57bbefdc3a973e442f50b7c17269e9ab789a4e76e531dcc22ca7ea930b2b3ccc" + "sha256": "b6109503a5606187dffc6754b565e7909f250197c2e67ad2466c33e6d16f18db" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "783f5e1a06525924cc58ddb9f5e577f586703553" + "tree": "7a0e8bd776f6ab661db04d99e6a162d423c7402a" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -139,9 +139,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71246424, + "bytes": 71247176, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "975b87ff13ed9d3d17fa77f0b48b47450a4e1d3e68b1ba6a53907540aa8a9b92" + "sha256": "9f83b0d9f50c3b59736ae843ffa503bd678654b571b7ff4ad6109ffd66af2e87" }, { "bytes": 70167, @@ -154,9 +154,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 71298224, + "bytes": 71299416, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "ad723743c7dec49a87559d72deb6f1d976718693e88cc324291c4d55ce795c5b" + "sha256": "b29a77aba06a05e8901628bf83a8e02abfee0991dca60f1306e2c6a47e1f1bdb" }, { "bytes": 492997, @@ -176,17 +176,17 @@ { "bytes": 61732, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "60f5dd2de192cae21c908a57bab5f020b07303bfd967a7862fdd94bb3356237d" + "sha256": "d345cf081f8e930146fee681e2850ffef638cde01f44e892fe5d29e187f6f511" }, { "bytes": 61576, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "3f1f0c4b9cd588994ff448ccf2d9114e026328ac16f0662972fcb09859a1896d" + "sha256": "d69260103114a7ce7775892be8a66606e7107335f25b2595aae514ad52674cbe" }, { "bytes": 61572, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "57bbefdc3a973e442f50b7c17269e9ab789a4e76e531dcc22ca7ea930b2b3ccc" + "sha256": "b6109503a5606187dffc6754b565e7909f250197c2e67ad2466c33e6d16f18db" } ], "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 = "783f5e1a06525924cc58ddb9f5e577f586703553" -manifest_sha256 = "3b77261735a12783b64b05d33c730b5999bbef11115d3368b4e3f5f3bc0130a1" +source_tree = "7a0e8bd776f6ab661db04d99e6a162d423c7402a" +manifest_sha256 = "99ff33807478af02891b42090d57f6bcb5a2ea56eef941451797743af89667e0" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -715,10 +715,10 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 135116, - "git_blob": "f8f68924ffb05011e8e20e1e310c003738c7ae2f", + "bytes": 138718, + "git_blob": "92d2a82d70f7f16d7db9076b20158b90560f1c64", "mode": "100644", - "sha256": "d77c115493a88799fd160ba14209680db60a1c65cfc6dd692a20b285e18d1ef3" + "sha256": "800d7c8e8adb3ca6e7206955a1f84afea76f59ef5c43ee083d7634ac101d6c0a" }, "core/crates/tera_core/src/runtime/sdk.rs": { "bytes": 20647, @@ -1340,6 +1340,6 @@ } }, "policy": "staged_inputs", - "tree": "783f5e1a06525924cc58ddb9f5e577f586703553" + "tree": "7a0e8bd776f6ab661db04d99e6a162d423c7402a" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -711,10 +711,10 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 135116, - "git_blob": "f8f68924ffb05011e8e20e1e310c003738c7ae2f", + "bytes": 138718, + "git_blob": "92d2a82d70f7f16d7db9076b20158b90560f1c64", "mode": "100644", - "sha256": "d77c115493a88799fd160ba14209680db60a1c65cfc6dd692a20b285e18d1ef3" + "sha256": "800d7c8e8adb3ca6e7206955a1f84afea76f59ef5c43ee083d7634ac101d6c0a" }, "core/crates/tera_core/src/runtime/sdk.rs": { "bytes": 20647, @@ -1336,6 +1336,6 @@ } }, "policy": "staged_inputs", - "tree": "783f5e1a06525924cc58ddb9f5e577f586703553" + "tree": "7a0e8bd776f6ab661db04d99e6a162d423c7402a" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -711,10 +711,10 @@ "sha256": "76e3bddfc237dac63293d264d68991b489585136fe377e39dbba72aa3a1181f4" }, "core/crates/tera_core/src/runtime/product_surface/today.rs": { - "bytes": 135116, - "git_blob": "f8f68924ffb05011e8e20e1e310c003738c7ae2f", + "bytes": 138718, + "git_blob": "92d2a82d70f7f16d7db9076b20158b90560f1c64", "mode": "100644", - "sha256": "d77c115493a88799fd160ba14209680db60a1c65cfc6dd692a20b285e18d1ef3" + "sha256": "800d7c8e8adb3ca6e7206955a1f84afea76f59ef5c43ee083d7634ac101d6c0a" }, "core/crates/tera_core/src/runtime/sdk.rs": { "bytes": 20647, @@ -1336,6 +1336,6 @@ } }, "policy": "staged_inputs", - "tree": "783f5e1a06525924cc58ddb9f5e577f586703553" + "tree": "7a0e8bd776f6ab661db04d99e6a162d423c7402a" } } diff --git a/TeraTests/TeraTodayCacheFirstTests.swift b/TeraTests/TeraTodayCacheFirstTests.swift @@ -0,0 +1,159 @@ +@testable import TeraApp +import XCTest + +@MainActor +final class TeraTodayCacheFirstTests: XCTestCase { + func testInitialLocalContentAppearsBeforeAStalledRefreshAndSurvivesCancellation() async throws { + for starting in [true, false] { + for cards in [[], [TeraScopeFixtures.card("saved")]] { + let backend = try TeraScopeBackend() + await backend.setPage(page(cards)) + let (client, store) = try await makeStore(backend) + let refresh = await backend.pause(.refresh) + let task = Task { + if starting { + await store.start() + } else { + await store.reload() + } + } + await refresh.entered.wait() + XCTAssertEqual(store.cards, cards) + XCTAssertEqual(store.presentation.content, cards.isEmpty ? .empty : .available) + XCTAssertEqual(store.presentation.refresh, .refreshing) + XCTAssertEqual(store.presentation.freshness, .unconfirmed) + XCTAssertFalse(store.presentation.isReading) + let counts = await backend.counts + XCTAssertEqual(counts[.page], 1) + XCTAssertEqual(counts[.refresh], 1) + task.cancel() + await task.value + XCTAssertEqual(store.cards, cards) + XCTAssertEqual(store.presentation.refresh, .idle) + store.stop() + // Release only after the caller has completed without a relay result. + await refresh.resume.open() + _ = try await client.stop() + } + } + } + + func testSuccessfulRefreshReplacesTheAlreadyVisibleLocalPage() async throws { + let backend = try TeraScopeBackend() + await backend.setPage(page([TeraScopeFixtures.card("saved")])) + let (client, store) = try await makeStore(backend) + let refresh = await backend.pause(.refresh) + let task = Task { await store.reload() } + await refresh.entered.wait() + XCTAssertEqual(store.cards.map(\.id), ["saved"]) + await backend.setPage(page([TeraScopeFixtures.card("updated")])) + let read = await backend.pause(.page) + await refresh.resume.open() + await read.entered.wait() + XCTAssertEqual(store.cards.map(\.id), ["saved"]) + XCTAssertTrue(store.presentation.isReading) + XCTAssertEqual(store.presentation.freshness, .unconfirmed) + await read.resume.open() + await task.value + XCTAssertEqual(store.cards.map(\.id), ["updated"]) + XCTAssertEqual(store.presentation.freshness, .refreshed(contentGeneration: 1)) + _ = try await client.stop() + } + + func testCancelingTheFirstLocalReadDoesNotStartRefreshOrClaimNetworkActivity() async throws { + let backend = try TeraScopeBackend() + let (client, store) = try await makeStore(backend) + let read = await backend.pause(.page) + let task = Task { await store.reload() } + await read.entered.wait() + XCTAssertEqual(store.presentation.refresh, .idle) + XCTAssertTrue(store.presentation.isReading) + XCTAssertEqual(store.presentation.content, .notLoaded) + task.cancel() + await task.value + XCTAssertFalse(store.presentation.isReading) + XCTAssertEqual(store.presentation.refresh, .idle) + let counts = await backend.counts + XCTAssertNil(counts[.refresh]) + await read.resume.open() + _ = try await client.stop() + } + + func testLateRefreshedPageCannotReplaceANewerLocalReload() async throws { + let backend = try TeraScopeBackend() + let (client, store) = try await makeStore(backend) + let refresh = await backend.pause(.refresh) + let old = Task { await store.reload() } + await refresh.entered.wait() + await backend.setPage(page([TeraScopeFixtures.card("obsolete")])) + let oldPage = await backend.pause(.page) + await refresh.resume.open() + await oldPage.entered.wait() + await backend.setPage(page([TeraScopeFixtures.card("current")])) + await store.reload(refreshProjection: false) + await oldPage.resume.open() + await old.value + XCTAssertEqual(store.cards.map(\.id), ["current"]) + XCTAssertEqual(store.presentation.freshness, .unconfirmed) + XCTAssertFalse(store.presentation.isReading) + _ = try await client.stop() + } + + func testLateCachedPaginationCannotAppendToTheRefreshedPageAtTheSameTime() async throws { + let backend = try TeraScopeBackend() + await backend.setPage(page([TeraScopeFixtures.card("saved")], next: "old-next")) + await backend.setPage(page([TeraScopeFixtures.card("old-next")]), cursor: "old-next") + let (client, store) = try await makeStore(backend) + let refresh = await backend.pause(.refresh) + let reload = Task { await store.reload() } + await refresh.entered.wait() + XCTAssertTrue(store.canLoadNextPage) + let oldPage = await backend.pause(.page) + let pagination = Task { await store.loadNextPage() } + await oldPage.entered.wait() + await backend.setPage(page([TeraScopeFixtures.card("updated")], next: "new-next")) + await backend.setPage(page([TeraScopeFixtures.card("new-next")]), cursor: "new-next") + await refresh.resume.open() + await reload.value + XCTAssertEqual(store.cards.map(\.id), ["updated"]) + XCTAssertFalse(store.isLoadingNextPage) + await oldPage.resume.open() + await pagination.value + XCTAssertEqual(store.cards.map(\.id), ["updated"]) + await store.loadNextPage() + XCTAssertEqual(store.cards.map(\.id), ["updated", "new-next"]) + _ = try await client.stop() + } + + func testScopeChangeDuringRefreshClearsCacheAndRejectsLateSuccess() async throws { + let backend = try TeraScopeBackend() + let (client, store) = try await makeStore(backend) + let refresh = await backend.pause(.refresh) + let old = Task { await store.reload() } + await refresh.entered.wait() + XCTAssertEqual(store.cards.map(\.id), ["wss://first.example"]) + store.configure(snapshot: TeraScopeFixtures.snapshot(relay: "second")) + XCTAssertTrue(store.cards.isEmpty) + XCTAssertEqual(store.presentation.content, .notLoaded) + await store.reload(refreshProjection: false) + let counts = await backend.counts + await refresh.resume.open() + await old.value + XCTAssertEqual(store.cards.map(\.id), ["wss://second.example"]) + XCTAssertEqual(store.presentation.freshness, .unconfirmed) + let current = await backend.counts + XCTAssertEqual(current[.page], counts[.page]) + _ = try await client.stop() + } + + private func makeStore(_ backend: TeraScopeBackend) async throws -> (TeraRuntimeClient, TeraTodayStore) { + let client = try await TeraScopeFixtures.client(backend) + let store = TeraTodayStore(runtimeClient: client, clock: .fixed(unixSeconds: 1)) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + return (client, store) + } + + private func page(_ cards: [TeraTodayCard], next: String? = nil) -> TeraTodayPage { + TeraTodayPage(asOfUnixSeconds: 1, items: cards, nextCursor: next) + } +} diff --git a/TeraTests/TeraTodayPresentationTests.swift b/TeraTests/TeraTodayPresentationTests.swift @@ -19,8 +19,11 @@ final class TeraTodayPresentationTests: XCTestCase { await store.reload(refreshProjection: false) } let expected = store.presentation.content + let firstRead = await backend.pause(.page, fails: true) let pause = await backend.pause(.page, fails: true) let task = Task { await store.reload() } + await firstRead.entered.wait() + await firstRead.resume.open() await pause.entered.wait() XCTAssertEqual(store.presentation.refresh, .completed) XCTAssertEqual(store.presentation.content, expected) @@ -127,12 +130,15 @@ final class TeraTodayPresentationTests: XCTestCase { let (client, store) = try await makeStore(backend) let refreshing = await backend.pause(.refresh, fails: true) let reading = await backend.pause(.page, fails: true) + let rereading = await backend.pause(.page, fails: true) let task = Task { await store.reload() } + await reading.entered.wait() + await reading.resume.open() await refreshing.entered.wait() await refreshing.resume.open() - await reading.entered.wait() + await rereading.entered.wait() XCTAssertTrue(store.presentation.isReading) - await reading.resume.open() + await rereading.resume.open() await task.value XCTAssertEqual(store.presentation.content, .notLoaded) XCTAssertNotNil(store.presentation.readFailure) @@ -149,7 +155,7 @@ final class TeraTodayPresentationTests: XCTestCase { if cached != nil { await store.reload(refreshProjection: false) } - let expected = store.presentation.content + let expected: TeraTodayContentAvailability = (cached ?? []).isEmpty ? .empty : .available let pause = await backend.pause(.refresh, fails: fails) let task = Task { await store.reload() } await pause.entered.wait() diff --git a/TeraTests/TeraTodayStoreTests.swift b/TeraTests/TeraTodayStoreTests.swift @@ -151,7 +151,18 @@ final class TeraTodayStoreTests: XCTestCase { } @MainActor - private func assertCachedRefreshFailure(code: String, expected: TeraTodayFailure) async throws { + func testOfflineRefreshStillShowsTheGenuineEmptyLocalPage() async throws { + try await assertCachedRefreshFailure( + code: "today_relay_offline", + expected: .offline(message: TeraUserMessages.text(.networkUnavailable)), + hasCachedCards: false + ) + } + + @MainActor + private func assertCachedRefreshFailure( + code: String, expected: TeraTodayFailure, hasCachedCards: Bool = true + ) async throws { let context = makeContext(id: "offline", label: "Offline") let cached = makeCard(id: "cached", type: .update) let failure = TeraRuntimeFailure( @@ -168,8 +179,8 @@ final class TeraTodayStoreTests: XCTestCase { pages: [ "offline:first": TeraTodayPage( asOfUnixSeconds: 1_800_000_100, - items: [cached], - nextCursor: "cached-next" + items: hasCachedCards ? [cached] : [], + nextCursor: hasCachedCards ? "cached-next" : nil ), "offline:cached-next": TeraTodayPage( asOfUnixSeconds: 1_800_000_100, @@ -188,12 +199,12 @@ final class TeraTodayStoreTests: XCTestCase { await store.reload() - XCTAssertEqual(store.cards.map(\.id), ["cached"]) - XCTAssertEqual(store.presentation.content, .available) + XCTAssertEqual(store.cards.map(\.id), hasCachedCards ? ["cached"] : []) + XCTAssertEqual(store.presentation.content, hasCachedCards ? .available : .empty) XCTAssertEqual(store.presentation.refresh, .failed(expected)) await store.loadNextPage() - XCTAssertEqual(store.cards.map(\.id), ["cached", "cached-next"]) - XCTAssertEqual(store.presentation.content, .available) + XCTAssertEqual(store.cards.map(\.id), hasCachedCards ? ["cached", "cached-next"] : []) + XCTAssertEqual(store.presentation.content, hasCachedCards ? .available : .empty) XCTAssertEqual(store.presentation.refresh, .failed(expected)) _ = try await client.stop() } diff --git a/core/crates/tera_core/src/runtime/product_surface/today.rs b/core/crates/tera_core/src/runtime/product_surface/today.rs @@ -457,9 +457,19 @@ impl TeraRuntime { .as_of .filter(|value| *value != 0) .ok_or(TodayError::InvalidRequest)?; - let state = load_state(storage, context, algorithm_generation) - .await? - .ok_or(TodayError::ProjectionMissing)?; + let state = match load_state(storage, context, algorithm_generation).await? { + Some(state) => state, + None => { + // A first local read must not need a prior relay refresh. + // Materialize only already admitted local events; errors + // remain errors rather than becoming an empty feed. + self.phase1_refresh_today(context, as_of, TodayProjectionUpdate::Incremental) + .await?; + load_state(storage, context, algorithm_generation) + .await? + .ok_or(TodayError::ProjectionMissing)? + } + }; if state.store_generation != *event_status.generation().as_bytes() { return Err(CursorError::Stale.into()); } @@ -2461,6 +2471,90 @@ mod tests { } #[tokio::test] + async fn first_local_page_materializes_an_empty_store_without_relay_refresh() { + let runtime = TeraRuntime::test_memory().expect("runtime"); + let context = context(None, 1); + let page = runtime + .phase1_today_page(&context, TodayPageRequest::first(20, 2_000_000_200)) + .await + .expect("local empty page"); + assert!(page.items.is_empty()); + assert!(page.next_cursor.is_none()); + assert_eq!(page.as_of, 2_000_000_200); + let state = load_state( + runtime.client.storage().expect("storage"), + &context, + projection_generation().expect("generation"), + ) + .await + .expect("local projection") + .expect("materialized"); + assert_eq!(state.source_events, 0); + assert!(state.cards.is_empty()); + } + + #[tokio::test] + async fn first_local_page_projects_admitted_events_without_prior_projection() { + let runtime = TeraRuntime::test_memory().expect("runtime"); + let context = context(None, 1); + let storage = runtime.client.storage().expect("storage"); + EventStore::admit( + storage, + visible_admission( + signed(1, Vec::new(), "Already saved locally", 2_000_000_000), + 2_000_000_100_000, + ), + ) + .await + .expect("admitted event"); + assert!( + load_state(storage, &context, projection_generation().unwrap()) + .await + .unwrap() + .is_none() + ); + let page = runtime + .phase1_today_page(&context, TodayPageRequest::first(20, 2_000_000_200)) + .await + .expect("local page"); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].card.content, "Already saved locally"); + } + + #[tokio::test] + async fn first_local_page_preserves_corrupt_projection_errors() { + let runtime = TeraRuntime::test_memory().expect("runtime"); + let context = context(None, 1); + let storage = runtime.client.storage().expect("storage"); + let key = projection_document_key(&context); + let corrupt = b"invalid projection".to_vec(); + ProjectionStore::put_projection_document( + storage, + projection_id().unwrap(), + projection_generation().unwrap(), + ProjectionDocument::new(key.clone(), corrupt.clone()).unwrap(), + ) + .await + .unwrap(); + assert!(matches!( + runtime + .phase1_today_page(&context, TodayPageRequest::first(20, 2_000_000_200)) + .await, + Err(TodayError::CorruptProjection) + )); + let retained = ProjectionStore::projection_document( + storage, + projection_id().unwrap(), + projection_generation().unwrap(), + key, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(retained.value(), corrupt); + } + + #[tokio::test] async fn equal_timestamp_pages_are_complete_and_remain_frozen_across_ingest() { let runtime = TeraRuntime::test_memory().expect("runtime"); let context = context(None, 1); diff --git a/project.yml b/project.yml @@ -62,6 +62,7 @@ targets: - path: TeraTests/TeraStateMigrationTests.swift - path: TeraTests/TeraTodayStoreTests.swift - path: TeraTests/TeraTodayPresentationTests.swift + - path: TeraTests/TeraTodayCacheFirstTests.swift - path: TeraTests/TeraAddSchemaFixtures.swift - path: TeraTests/TeraScopeFixtures.swift - path: TeraTests/TeraScopeBackend.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": "3b77261735a12783b64b05d33c730b5999bbef11115d3368b4e3f5f3bc0130a1", + "ffi_provenance_sha256": "99ff33807478af02891b42090d57f6bcb5a2ea56eef941451797743af89667e0", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "6b5ce897d5273290febc8b831663e12dea759cb0084f182ba7949e1c593fc410", - "xcode_project_sha256": "c62334b1d43ba931d096a9a9895affc3e722b266191a52beda11e894f0f9318f" + "xcode_project_sha256": "f7204400383da3d0aacd550654a772742352f99942153c3d8c4b88c685f979b3" }, "disposition": "unsigned", "platforms": [ @@ -22,7 +22,7 @@ "lib_revision": "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "94ae067a374726cdaf6b4ca0a5e44663c57fcdc5334060c5ffef5e79cfbf04c0", - "tera_ffi_source_tree": "783f5e1a06525924cc58ddb9f5e577f586703553", + "tera_ffi_source_tree": "7a0e8bd776f6ab661db04d99e6a162d423c7402a", "xcode_package_lock_sha256": "c7f41934ea25f7a287bdc4f3a6ecabbf09a3a0bdd0f5a3e58183f355ca814096" }, "version": "0.1.0-alpha"