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:
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"