Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions convex/imageAssetLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,37 @@ afterEach(() => {
});

describe("image asset lifecycle", () => {
test("stale pending uploads require a fresh intent even when referenced", async () => {
vi.useFakeTimers();
const fetchMock = mockR2Deletes();
const { t, owner } = await createHarness();
await seedStrategy(owner);
const { strategy, pages } = await getStrategyAndPages(t);
const staleAt = Date.now() - 48 * 60 * 60 * 1000;
await t.run(async (ctx) => {
await ctx.db.insert("imageAssets", {
publicId: "offline-image", strategyId: strategy._id, provider: "r2",
objectKey: "offline/image.png", uploadAttemptPublicId: "offline-attempt",
uploadStatus: "pending", fileExtension: ".png", mimeType: "image/png",
createdAt: staleAt, updatedAt: staleAt,
});
await ctx.db.insert("elements", {
publicId: "offline-image", strategyId: strategy._id, pageId: pages[0]!._id,
elementType: "image", payloadKind: "image", payloadVersion: 1, payload: imagePayload("offline-image"),
sortIndex: 0, revision: 1, deleted: false, createdAt: staleAt, updatedAt: staleAt,
});
});
await t.mutation(markStaleImageUploadsDeleted, {});
await t.finishAllScheduledFunctions(vi.runAllTimers);
expect(await allAssets(t)).toEqual([]);
expect(fetchMock).toHaveBeenCalled();
await expect(owner.action(completeUpload, {
clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION,
strategyPublicId, assetPublicId: "offline-image", provider: "r2",
uploadId: "offline-attempt", objectKey: "offline/image.png",
})).rejects.toThrow(/Upload intent not found/);
});

test("page deletion removes only assets unreferenced by remaining Pages and Lineups", async () => {
vi.useFakeTimers();
const fetchMock = mockR2Deletes();
Expand Down
4 changes: 4 additions & 0 deletions lib/collab/convex_strategy_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,10 @@ bool isTypedConvexUnauthenticatedError(Object error) {
error.rawCode == ConvexErrorCode.unauthenticated.wireName);
}

bool isMissingImageUploadIntentError(Object error) =>
error is ConvexFunctionException &&
error.code == ConvexErrorCode.uploadIntentNotFound;

CloudFolderEntry _folderEntry(FoldersListTreeResultItem folder) {
return (
folder: Folder(
Expand Down
9 changes: 6 additions & 3 deletions lib/collab/durable_cloud_media_outbox.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ const durableCloudMediaOutboxVersionKey = '__media_outbox_record_version__';

Future<void> prepareDurableCloudMediaOutbox() async {
final box = Hive.box<dynamic>(HiveBoxNames.cloudMediaOutboxBox);
if (box.get(durableCloudMediaOutboxVersionKey) ==
durableCloudMediaOutboxRecordVersion) {
final version = box.get(durableCloudMediaOutboxVersionKey);
if (version == durableCloudMediaOutboxRecordVersion) {
return;
}
if (box.isNotEmpty) {
if (version != null && version != 1) {
throw StateError(
'The media outbox has an unsupported record version. '
'Refusing to discard pending media work.',
);
}
// Prerelease v1 records had no owning account. Keep them byte-for-byte
// for recovery, and let load() report them as unreadable saved work.
// Never assign them to whichever account happens to sign in next.
await box.put(
durableCloudMediaOutboxVersionKey,
durableCloudMediaOutboxRecordVersion,
Expand Down
15 changes: 12 additions & 3 deletions lib/providers/collab/active_page_live_sync_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class ActivePageLiveSyncNotifier extends Notifier<ActivePageLiveSyncState> {
for (final intent in intents) {
final revision = intent.ack.appliedRevision;
final key = intent.entityKey;
if (revision == null || key.pageId != state.hydratedPageId) {
if (revision == null) {
continue;
}
final accepted = _normalizedAcceptedEntity(
Expand Down Expand Up @@ -205,7 +205,13 @@ class ActivePageLiveSyncNotifier extends Notifier<ActivePageLiveSyncState> {
PagePatchOp(:final payload) => _NormalizedEntity(
key: key,
overlayEntityType: ActivePageOverlayEntityType.pageDescriptor,
payload: payload,
// The canvas tracks side only. Rename acks must not replace that
// baseline, or introduce fields the canvas never compares.
payload: <String, dynamic>{
if (previous != null) ..._decodeObject(previous.payload),
if (payload.containsKey('isAttack'))
'isAttack': payload['isAttack'],
},
sortIndex: null,
revision: revision,
deleted: false,
Expand Down Expand Up @@ -515,6 +521,7 @@ class ActivePageLiveSyncNotifier extends Notifier<ActivePageLiveSyncState> {
ActivePageProjectedState? projectPageState({
required String strategyPublicId,
required String pageId,
Set<EntitySyncKey> excludedOverlays = const {},
}) {
setContext(strategyPublicId: strategyPublicId, activePageId: pageId);
final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull;
Expand Down Expand Up @@ -557,7 +564,9 @@ class ActivePageLiveSyncNotifier extends Notifier<ActivePageLiveSyncState> {
var projectedIsAttack = page.isAttack;

final pageOverlays = state.overlayByEntityKey.entries.where(
(entry) => entry.key.pageId == page.publicId,
(entry) =>
entry.key.pageId == page.publicId &&
!excludedOverlays.contains(entry.key),
);
for (final entry in pageOverlays) {
final overlay = entry.value;
Expand Down
Loading
Loading