diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec4799bf..ab8217b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,10 @@ jobs: # Fetch all branch history so that ref exists on PR and push runs. fetch-depth: 0 + - name: Test Release Safety Policy + shell: pwsh + run: powershell -ExecutionPolicy Bypass -File scripts/test_release_safety.ps1 + - uses: actions/setup-node@v4 with: node-version: 22 @@ -158,7 +162,7 @@ jobs: run: fvm flutter analyze --no-fatal-infos - name: Build Web Client shell: pwsh - run: fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons + run: fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development - name: Run Tests shell: pwsh run: fvm flutter test @@ -170,7 +174,7 @@ jobs: cargo test --manifest-path third_party/convex_rs/Cargo.toml - name: Build Windows Client shell: pwsh - run: fvm flutter build windows --no-tree-shake-icons + run: fvm flutter build windows --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development - name: Build Windows Installer shell: pwsh @@ -278,4 +282,4 @@ jobs: cargo test --manifest-path third_party/convex_rs/Cargo.toml - name: Build Linux Client - run: fvm flutter build linux --no-tree-shake-icons + run: fvm flutter build linux --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development diff --git a/.github/workflows/deploy-convex-production.yml b/.github/workflows/deploy-convex-production.yml new file mode 100644 index 00000000..cc2afc85 --- /dev/null +++ b/.github/workflows/deploy-convex-production.yml @@ -0,0 +1,68 @@ +name: Deploy Convex Production + +on: + workflow_dispatch: + inputs: + confirmation: + description: Type deploy-production to confirm this production backend deploy. + type: string + required: true + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + environment: Production + + steps: + - name: Guard Production Deploy + shell: bash + env: + CONFIRMATION: ${{ inputs.confirmation }} + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "The production Convex backend can only deploy from branch main. Current ref: $GITHUB_REF" + exit 1 + fi + if [[ "$CONFIRMATION" != "deploy-production" ]]; then + echo "Confirmation must be exactly: deploy-production" + exit 1 + fi + + - name: Checkout Production Source + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Require Production Convex Deploy Key + shell: bash + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_PRODUCTION_DEPLOY_KEY }} + run: | + if [[ -z "$CONVEX_DEPLOY_KEY" ]]; then + echo "Add CONVEX_PRODUCTION_DEPLOY_KEY to the GitHub Production environment." + exit 1 + fi + if [[ "$CONVEX_DEPLOY_KEY" != prod:* ]]; then + echo "CONVEX_PRODUCTION_DEPLOY_KEY must be a production deploy key with the prod: prefix." + exit 1 + fi + + - name: Install Convex Dependencies + run: npm ci + + - name: Check Convex Types + run: npx tsc --noEmit + + - name: Run Convex Tests + run: npm run test:convex + + - name: Deploy Convex Production Backend + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_PRODUCTION_DEPLOY_KEY }} + run: npx convex deploy --typecheck enable --message "GitHub Actions $GITHUB_SHA" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 86dae011..0f517e64 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -46,7 +46,30 @@ permissions: contents: write jobs: + production-approval: + if: ${{ inputs.channel == 'stable' }} + runs-on: ubuntu-latest + environment: Production + + steps: + - name: Guard Stable Desktop Release + shell: bash + env: + PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Stable desktop releases can only run from branch main. Current ref: $GITHUB_REF" + exit 1 + fi + if [[ -z "$PRODUCTION_CONVEX_DEPLOYMENT_URL" || -z "$PRODUCTION_CONVEX_CLIENT_ID" ]]; then + echo "Set ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL and ICARUS_PRODUCTION_CONVEX_CLIENT_ID before releasing stable." + exit 1 + fi + build: + needs: production-approval + if: ${{ always() && (inputs.channel == 'prerelease' || needs.production-approval.result == 'success') }} runs-on: windows-latest steps: @@ -54,6 +77,15 @@ jobs: with: fetch-depth: 0 + - name: Run Release Preflight + shell: pwsh + env: + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} + run: >- + powershell -ExecutionPolicy Bypass -File scripts/assert_release_preflight.ps1 + -ReleaseTarget "${{ inputs.channel == 'stable' && 'stable-desktop' || 'prerelease-desktop' }}" + - uses: dart-lang/setup-dart@v1 - name: Add Pub Cache To PATH @@ -76,6 +108,8 @@ jobs: RELEASE_TITLE: ${{ inputs.release_title }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }} + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} run: | $args = @( "-ExecutionPolicy", "Bypass", diff --git a/.github/workflows/release-store.yml b/.github/workflows/release-store.yml index 3c142c1a..5657f3e5 100644 --- a/.github/workflows/release-store.yml +++ b/.github/workflows/release-store.yml @@ -25,12 +25,20 @@ permissions: jobs: build: runs-on: windows-latest + environment: Production steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Run Store Release Preflight + shell: pwsh + env: + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} + run: powershell -ExecutionPolicy Bypass -File scripts/assert_release_preflight.ps1 -ReleaseTarget store + - uses: dart-lang/setup-dart@v1 - name: Add Pub Cache To PATH @@ -55,6 +63,8 @@ jobs: shell: pwsh env: POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }} + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} run: powershell -ExecutionPolicy Bypass -File scripts/build_store_release.ps1 - name: Upload Store Artifacts diff --git a/README.md b/README.md index 1ebe064a..4691478d 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,13 @@ back to the installed build. ## Build ```bash -flutter build +flutter build --dart-define=ICARUS_CLOUD_ENVIRONMENT=development ``` +That command makes an internal build against the named development Convex +deployment. Use the release scripts in `docs/release_process.md` for stable or +Store artifacts. They require explicit production cloud configuration. + ## Versioning (Windows MSIX) There is a helper script for bumping versions across `pubspec.yaml` and `lib/const/settings.dart`. diff --git a/convex/accessControl.test.ts b/convex/accessControl.test.ts index 0de684cf..522f2698 100644 --- a/convex/accessControl.test.ts +++ b/convex/accessControl.test.ts @@ -6,6 +6,7 @@ import { import { makeFunctionReference } from "convex/server"; import { describe, expect, test } from "vitest"; import type { DataModel } from "./_generated/dataModel"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; import schema from "./schema"; import { modules } from "./test.setup"; @@ -19,6 +20,7 @@ const createStrategy = makeFunctionReference<"mutation">( "strategies:createWithInitialPage", ); const updateStrategy = makeFunctionReference<"mutation">("strategies:update"); +const moveStrategy = makeFunctionReference<"mutation">("strategies:move"); const deleteStrategy = makeFunctionReference<"mutation">("strategies:delete"); const listStrategies = makeFunctionReference<"query">( "strategies:listForFolder", @@ -55,9 +57,15 @@ async function createHarness(): Promise<{ const b = t.withIdentity(identity("b")); const c = t.withIdentity(identity("c")); await Promise.all([ - a.mutation(ensureCurrentUser, {}), - b.mutation(ensureCurrentUser, {}), - c.mutation(ensureCurrentUser, {}), + a.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }), + b.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }), + c.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }), ]); return { t, a, b, c }; } @@ -69,6 +77,7 @@ async function seedStrategy( folderPublicId?: string, ) { await owner.mutation(createStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, publicId: strategyPublicId, name: "A private Strategy", mapData: "ascent", @@ -100,12 +109,16 @@ describe("A/B/C access boundary", () => { ).rejects.toThrow("Forbidden"); await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, targetType: "strategy", targetPublicId: strategyPublicId, token: "strategy-viewer-token", role: "viewer", }); - await b.mutation(redeemShare, { token: "strategy-viewer-token" }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "strategy-viewer-token", + }); await expect( b.query(getStrategyShell, { strategyPublicId }), @@ -121,6 +134,7 @@ describe("A/B/C access boundary", () => { }); await expect( b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, name: "Viewer must not write", @@ -128,6 +142,7 @@ describe("A/B/C access boundary", () => { ).rejects.toThrow("Forbidden"); await expect( b.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, pagePublicId: "viewer-page", @@ -138,14 +153,19 @@ describe("A/B/C access boundary", () => { ).rejects.toThrow("Forbidden"); await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, targetType: "strategy", targetPublicId: strategyPublicId, token: "strategy-editor-token", role: "editor", }); - await b.mutation(redeemShare, { token: "strategy-editor-token" }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "strategy-editor-token", + }); await expect( b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, name: "Edited by B", @@ -153,6 +173,7 @@ describe("A/B/C access boundary", () => { ).resolves.toMatchObject({ ok: true, revision: 1 }); await expect( b.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 1, pagePublicId: "editor-page", @@ -172,15 +193,20 @@ describe("A/B/C access boundary", () => { }); await a.mutation(revokeShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, targetType: "strategy", targetPublicId: strategyPublicId, token: "strategy-editor-token", }); await expect( - c.mutation(redeemShare, { token: "strategy-editor-token" }), + c.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "strategy-editor-token", + }), ).rejects.toThrow("Share link revoked"); await expect( b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 2, name: "B keeps redeemed access", @@ -195,10 +221,12 @@ describe("A/B/C access boundary", () => { const strategyPublicId = "nested-strategy"; await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, publicId: rootFolderPublicId, name: "A root", }); await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, publicId: childFolderPublicId, name: "A child", parentFolderPublicId: rootFolderPublicId, @@ -218,12 +246,16 @@ describe("A/B/C access boundary", () => { ).rejects.toThrow("Forbidden"); await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, targetType: "folder", targetPublicId: rootFolderPublicId, token: "folder-viewer-token", role: "viewer", }); - await b.mutation(redeemShare, { token: "folder-viewer-token" }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "folder-viewer-token", + }); await expect( b.query(listFolderTree, { scope: "shared" }), @@ -245,6 +277,7 @@ describe("A/B/C access boundary", () => { ]); await expect( b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, name: "Viewer must not write", @@ -252,20 +285,26 @@ describe("A/B/C access boundary", () => { ).rejects.toThrow("Forbidden"); await expect( b.mutation(updateFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, folderPublicId: childFolderPublicId, name: "Viewer must not rename", }), ).rejects.toThrow("Forbidden"); await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, targetType: "folder", targetPublicId: rootFolderPublicId, token: "folder-editor-token", role: "editor", }); - await b.mutation(redeemShare, { token: "folder-editor-token" }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "folder-editor-token", + }); await expect( b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, name: "Inherited editor write", @@ -273,12 +312,14 @@ describe("A/B/C access boundary", () => { ).resolves.toMatchObject({ ok: true, revision: 1 }); await expect( b.mutation(updateFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, folderPublicId: rootFolderPublicId, name: "Editors are not owners", }), ).rejects.toThrow("Forbidden"); await expect( b.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, targetType: "folder", targetPublicId: rootFolderPublicId, token: "editor-cannot-reshare", @@ -287,18 +328,23 @@ describe("A/B/C access boundary", () => { ).rejects.toThrow("Forbidden"); await a.mutation(revokeShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, targetType: "folder", targetPublicId: rootFolderPublicId, token: "folder-editor-token", }); await expect( - c.mutation(redeemShare, { token: "folder-editor-token" }), + c.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "folder-editor-token", + }), ).rejects.toThrow("Share link revoked"); await expect( c.query(listFolderTree, { scope: "shared" }), ).resolves.toEqual([]); await expect( b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 1, name: "Redeemed editor remains durable", @@ -306,19 +352,138 @@ describe("A/B/C access boundary", () => { ).resolves.toMatchObject({ ok: true, revision: 2 }); }); + test("only the owner can move a directly shared Strategy", async () => { + const { a, b } = await createHarness(); + const sourceFolderPublicId = "direct-move-source"; + const targetFolderPublicId = "direct-move-target"; + const strategyPublicId = "directly-shared-strategy"; + + await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: sourceFolderPublicId, + name: "Source", + }); + await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: targetFolderPublicId, + name: "Target", + }); + await seedStrategy( + a, + strategyPublicId, + "directly-shared-page", + sourceFolderPublicId, + ); + await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: "direct-move-editor-token", + role: "editor", + }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "direct-move-editor-token", + }); + + await expect( + b.mutation(moveStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + folderPublicId: targetFolderPublicId, + }), + ).rejects.toThrow("Forbidden"); + await expect( + a.query(listStrategies, { + folderPublicId: sourceFolderPublicId, + scope: "owned", + }), + ).resolves.toMatchObject([ + { publicId: strategyPublicId, revision: 0 }, + ]); + + await expect( + a.mutation(moveStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + folderPublicId: targetFolderPublicId, + }), + ).resolves.toMatchObject({ ok: true, revision: 1 }); + await expect( + a.query(listStrategies, { + folderPublicId: targetFolderPublicId, + scope: "owned", + }), + ).resolves.toMatchObject([ + { publicId: strategyPublicId, revision: 1 }, + ]); + }); + + test("an inherited folder editor cannot move a Strategy out of the share", async () => { + const { a, b } = await createHarness(); + const sharedFolderPublicId = "inherited-move-source"; + const strategyPublicId = "inherited-editor-strategy"; + + await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: sharedFolderPublicId, + name: "Shared source", + }); + await seedStrategy( + a, + strategyPublicId, + "inherited-editor-page", + sharedFolderPublicId, + ); + await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "folder", + targetPublicId: sharedFolderPublicId, + token: "inherited-move-editor-token", + role: "editor", + }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "inherited-move-editor-token", + }); + + await expect( + b.mutation(moveStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + }), + ).rejects.toThrow("Forbidden"); + await expect( + b.query(listStrategies, { + folderPublicId: sharedFolderPublicId, + scope: "shared", + }), + ).resolves.toMatchObject([ + { publicId: strategyPublicId, role: "editor", revision: 0 }, + ]); + }); + test("deleting a Strategy removes its access records", async () => { const { t, a, b } = await createHarness(); const strategyPublicId = "deleted-strategy"; await seedStrategy(a, strategyPublicId, "deleted-page"); await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, targetType: "strategy", targetPublicId: strategyPublicId, token: "deleted-strategy-token", role: "editor", }); - await b.mutation(redeemShare, { token: "deleted-strategy-token" }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "deleted-strategy-token", + }); await a.mutation(deleteStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, }); diff --git a/convex/cloudProtocol.test.ts b/convex/cloudProtocol.test.ts new file mode 100644 index 00000000..74ec1771 --- /dev/null +++ b/convex/cloudProtocol.test.ts @@ -0,0 +1,231 @@ +import { convexTest } from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { describe, expect, test } from "vitest"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { modules } from "./test.setup"; + +const publicMutations = [ + ["folders:create", { publicId: "folder", name: "Folder" }], + ["folders:update", { folderPublicId: "folder" }], + ["folders:move", { folderPublicId: "folder" }], + ["folders:delete", { folderPublicId: "folder" }], + [ + "invites:create", + { strategyPublicId: "strategy", token: "token", role: "viewer" }, + ], + ["invites:redeem", { token: "token" }], + ["invites:revoke", { strategyPublicId: "strategy", token: "token" }], + [ + "ops:applyBatch", + { strategyPublicId: "strategy", clientId: "client", ops: [] }, + ], + [ + "pages:add", + { + strategyPublicId: "strategy", + expectedRevision: 0, + pagePublicId: "page", + name: "Page", + sortIndex: 0, + isAttack: true, + }, + ], + [ + "pages:rename", + { + strategyPublicId: "strategy", + pagePublicId: "page", + name: "Page", + expectedRevision: 0, + }, + ], + [ + "pages:delete", + { + strategyPublicId: "strategy", + pagePublicId: "page", + expectedRevision: 0, + }, + ], + [ + "pages:reorder", + { + strategyPublicId: "strategy", + orderedPagePublicIds: [], + expectedRevision: 0, + }, + ], + [ + "shares:create", + { + targetType: "strategy", + targetPublicId: "strategy", + token: "token", + role: "viewer", + }, + ], + [ + "shares:revoke", + { targetType: "strategy", targetPublicId: "strategy", token: "token" }, + ], + ["shares:redeem", { token: "token" }], + [ + "strategies:create", + { publicId: "strategy", name: "Strategy", mapData: "ascent" }, + ], + [ + "strategies:createWithInitialPage", + { + publicId: "strategy", + name: "Strategy", + mapData: "ascent", + initialPagePublicId: "page", + initialPageName: "Page 1", + initialPageIsAttack: true, + }, + ], + [ + "strategies:update", + { strategyPublicId: "strategy", expectedRevision: 0 }, + ], + [ + "strategies:move", + { strategyPublicId: "strategy", expectedRevision: 0 }, + ], + [ + "strategies:delete", + { strategyPublicId: "strategy", expectedRevision: 0 }, + ], + ["users:ensureCurrentUser", {}], +] as const; + +const publicWriteActions = [ + [ + "images:generateUploadUrl", + { + strategyPublicId: "strategy", + assetPublicId: "asset", + mimeType: "image/png", + fileExtension: ".png", + }, + ], + [ + "images:completeUpload", + { strategyPublicId: "strategy", assetPublicId: "asset" }, + ], + [ + "images:deleteAssetRef", + { strategyPublicId: "strategy", assetPublicId: "asset" }, + ], +] as const; + +async function captureError(promise: Promise) { + return promise.then( + () => null, + (caught: unknown) => caught as { data?: unknown }, + ); +} + +function expectUpgradeRequired(error: { data?: unknown } | null): void { + expect(error).not.toBeNull(); + expect(typeof error?.data).toBe("string"); + expect(JSON.parse(error?.data as string)).toEqual({ + code: "CLIENT_UPGRADE_REQUIRED", + message: "Client upgrade required", + }); +} + +describe("public cloud mutation protocol gate", () => { + test.each(publicMutations)("%s rejects an old protocol canonically", async ( + identifier, + args, + ) => { + const t = convexTest(schema, modules); + const mutation = makeFunctionReference<"mutation">(identifier); + const error = await t + .mutation(mutation, { + ...args, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION - 1, + }) + .then( + () => null, + (caught: unknown) => caught as { data?: unknown }, + ); + + expect(error).not.toBeNull(); + expect(typeof error?.data).toBe("string"); + expect(JSON.parse(error?.data as string)).toEqual({ + code: "CLIENT_UPGRADE_REQUIRED", + message: "Client upgrade required", + }); + }); + + test("a newer unknown protocol receives the same canonical error", async () => { + const t = convexTest(schema, modules); + const mutation = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", + ); + const error = await t + .mutation(mutation, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION + 1, + }) + .then( + () => null, + (caught: unknown) => caught as { data?: unknown }, + ); + + expect(JSON.parse(error?.data as string)).toEqual({ + code: "CLIENT_UPGRADE_REQUIRED", + message: "Client upgrade required", + }); + }); +}); + +describe("public cloud write action protocol gate", () => { + test.each(publicWriteActions)("%s rejects a missing protocol", async ( + identifier, + args, + ) => { + const t = convexTest(schema, modules); + const action = makeFunctionReference<"action">(identifier); + + const error = await captureError(t.action(action, args)); + + expect(error).not.toBeNull(); + }); + + test.each(publicWriteActions)("%s rejects an old protocol canonically", async ( + identifier, + args, + ) => { + const t = convexTest(schema, modules); + const action = makeFunctionReference<"action">(identifier); + + const error = await captureError( + t.action(action, { + ...args, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION - 1, + }), + ); + + expectUpgradeRequired(error); + }); + + test.each(publicWriteActions)( + "%s rejects an unknown future protocol canonically", + async (identifier, args) => { + const t = convexTest(schema, modules); + const action = makeFunctionReference<"action">(identifier); + + const error = await captureError( + t.action(action, { + ...args, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION + 1, + }), + ); + + expectUpgradeRequired(error); + }, + ); +}); diff --git a/convex/crons.ts b/convex/crons.ts index a62a0b67..0fa8b993 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -3,6 +3,10 @@ import { purgeOldOperationEventsRef, purgeOldTombstonesRef, } from "./maintenance"; +import { + markStaleImageUploadsDeletedRef, + sweepDeletedImageAssetsRef, +} from "./images"; const crons = cronJobs(); @@ -18,5 +22,17 @@ crons.interval( purgeOldTombstonesRef, {}, ); +crons.interval( + "mark-stale-image-uploads-deleted", + { hours: 1 }, + markStaleImageUploadsDeletedRef, + {}, +); +crons.interval( + "sweep-deleted-image-assets", + { hours: 1 }, + sweepDeletedImageAssetsRef, + {}, +); export default crons; diff --git a/convex/folders.test.ts b/convex/folders.test.ts index b1013ee1..87e3a331 100644 --- a/convex/folders.test.ts +++ b/convex/folders.test.ts @@ -6,6 +6,7 @@ import { import { makeFunctionReference } from "convex/server"; import { expect, test } from "vitest"; import type { DataModel } from "./_generated/dataModel"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; import schema from "./schema"; import { modules } from "./test.setup"; @@ -31,7 +32,9 @@ async function createHarness(): Promise<{ }> { const t = convexTest(schema, modules); const owner = t.withIdentity(identity); - await owner.mutation(ensureCurrentUser, {}); + await owner.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }); return { t, owner }; } @@ -41,6 +44,7 @@ async function seedFolder( parentFolderPublicId?: string, ) { await owner.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, publicId, name: publicId, parentFolderPublicId, @@ -69,6 +73,7 @@ test("folder move rejects self-parent without changing the folder", async () => await expect( owner.mutation(moveFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, folderPublicId: "root", parentFolderPublicId: "root", }), @@ -85,6 +90,7 @@ test("folder move rejects a descendant parent without changing the tree", async await expect( owner.mutation(moveFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, folderPublicId: "root", parentFolderPublicId: "grandchild", }), diff --git a/convex/folders.ts b/convex/folders.ts index 41181bc7..d6386902 100644 --- a/convex/folders.ts +++ b/convex/folders.ts @@ -8,6 +8,10 @@ import { requireCurrentUser, } from "./lib/auth"; import { getFolderByPublicId } from "./lib/entities"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; import { conflictError, forbiddenError, @@ -136,6 +140,7 @@ const folderScopeValidator = v.optional( export const create = mutation({ args: { + ...cloudProtocolArgs, publicId: v.string(), name: v.string(), parentFolderPublicId: v.optional(v.string()), @@ -148,6 +153,7 @@ export const create = mutation({ }, returns: createResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const user = await requireCurrentUser(ctx); const now = Date.now(); @@ -194,6 +200,7 @@ export const create = mutation({ export const update = mutation({ args: { + ...cloudProtocolArgs, folderPublicId: v.string(), name: v.optional(v.string()), iconId: v.optional(v.number()), @@ -208,6 +215,7 @@ export const update = mutation({ }, returns: okResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const folder = await getFolderByPublicId(ctx, args.folderPublicId); const { role } = await assertFolderRole(ctx, folder, "owner"); @@ -302,11 +310,13 @@ export const listTree = query({ export const move = mutation({ args: { + ...cloudProtocolArgs, folderPublicId: v.string(), parentFolderPublicId: v.optional(v.string()), }, returns: okResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const folder = await getFolderByPublicId(ctx, args.folderPublicId); const { role } = await assertFolderRole(ctx, folder, "owner"); @@ -336,10 +346,12 @@ export const move = mutation({ const deleteFolder = mutation({ args: { + ...cloudProtocolArgs, folderPublicId: v.string(), }, returns: okResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const folder = await getFolderByPublicId(ctx, args.folderPublicId); const { role } = await assertFolderRole(ctx, folder, "owner"); diff --git a/convex/function_spec.json b/convex/function_spec.json index 25546aba..d342d722 100644 --- a/convex/function_spec.json +++ b/convex/function_spec.json @@ -39358,6 +39358,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "color": { "fieldType": { "type": "string" @@ -39460,6 +39466,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "folderPublicId": { "fieldType": { "type": "string" @@ -39672,6 +39684,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "folderPublicId": { "fieldType": { "type": "string" @@ -39726,6 +39744,12 @@ }, "optional": true }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "color": { "fieldType": { "type": "string" @@ -39809,6 +39833,25 @@ "kind": "public" } }, + { + "args": { + "type": "object", + "value": { + "limit": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:claimDeletedImageAssets", + "returns": null, + "visibility": { + "kind": "internal" + } + }, { "args": { "type": "object", @@ -39881,6 +39924,12 @@ }, "optional": true }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "etag": { "fieldType": { "type": "string" @@ -40086,6 +40135,12 @@ }, "optional": false }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "strategyPublicId": { "fieldType": { "type": "string" @@ -40112,6 +40167,32 @@ "kind": "public" } }, + { + "args": { + "type": "object", + "value": { + "assetId": { + "fieldType": { + "tableName": "imageAssets", + "type": "id" + }, + "optional": false + }, + "r2ObjectDeleted": { + "fieldType": { + "type": "boolean" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:finalizeDeletedImageAsset", + "returns": null, + "visibility": { + "kind": "internal" + } + }, { "args": { "type": "object", @@ -40128,6 +40209,12 @@ }, "optional": true }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "fileExtension": { "fieldType": { "type": "string" @@ -40291,6 +40378,26 @@ "kind": "public" } }, + { + "args": { + "type": "object", + "value": { + "assetId": { + "fieldType": { + "tableName": "imageAssets", + "type": "id" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "images.js:getDeletedImageAssetTarget", + "returns": null, + "visibility": { + "kind": "internal" + } + }, { "args": { "type": "object", @@ -40527,15 +40634,13 @@ "args": { "type": "object", "value": { - "limit": { - "fieldType": { - "type": "number" - }, - "optional": true - }, - "staleBefore": { + "assetIds": { "fieldType": { - "type": "number" + "type": "array", + "value": { + "tableName": "imageAssets", + "type": "id" + } }, "optional": false }, @@ -40547,8 +40652,8 @@ } } }, - "functionType": "Query", - "identifier": "images.js:listStaleUploadDeletionTargets", + "functionType": "Mutation", + "identifier": "images.js:markDeletedAssetRefsForStrategy", "returns": null, "visibility": { "kind": "internal" @@ -40558,26 +40663,53 @@ "args": { "type": "object", "value": { - "assetIds": { + "assetPublicIds": { "fieldType": { "type": "array", "value": { - "tableName": "imageAssets", - "type": "id" + "type": "string" } }, "optional": false }, - "strategyPublicId": { + "pageId": { "fieldType": { - "type": "string" + "tableName": "pages", + "type": "id" + }, + "optional": false + }, + "strategyId": { + "fieldType": { + "tableName": "strategies", + "type": "id" }, "optional": false } } }, "functionType": "Mutation", - "identifier": "images.js:markDeletedAssetRefsForStrategy", + "identifier": "images.js:markDeletedPageImageAssets", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "strategyId": { + "fieldType": { + "tableName": "strategies", + "type": "id" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:markDeletedStrategyImageAssets", "returns": null, "visibility": { "kind": "internal" @@ -40691,18 +40823,79 @@ "fieldType": { "type": "number" }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:markStaleImageUploadsDeleted", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetIds": { + "fieldType": { + "type": "array", + "value": { + "tableName": "imageAssets", + "type": "id" + } + }, "optional": false }, - "strategyPublicId": { + "retryAfterMs": { "fieldType": { - "type": "string" + "type": "number" }, - "optional": false + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:releaseImageAssetDeletionClaims", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "delayMs": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:scheduleDeletedImageAssetSweep", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "limit": { + "fieldType": { + "type": "number" + }, + "optional": true } } }, "functionType": "Action", - "identifier": "images.js:sweepStaleUploadsForStrategy", + "identifier": "images.js:sweepDeletedImageAssets", "returns": null, "visibility": { "kind": "internal" @@ -40712,6 +40905,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "expiresAt": { "fieldType": { "type": "number" @@ -40937,6 +41136,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "token": { "fieldType": { "type": "string" @@ -40993,6 +41198,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "strategyPublicId": { "fieldType": { "type": "string" @@ -47715,6 +47926,13 @@ "type": "id" }, "optional": false + }, + "strategyId": { + "fieldType": { + "tableName": "strategies", + "type": "id" + }, + "optional": false } } }, @@ -140684,6 +140902,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "expectedRevision": { "fieldType": { "type": "number" @@ -140812,6 +141036,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "expectedRevision": { "fieldType": { "type": "number" @@ -140970,6 +141200,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "expectedRevision": { "fieldType": { "type": "number" @@ -141060,6 +141296,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "expectedRevision": { "fieldType": { "type": "number" @@ -141141,6 +141383,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "role": { "fieldType": { "type": "union", @@ -141293,6 +141541,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "token": { "fieldType": { "type": "string" @@ -141420,6 +141674,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "targetPublicId": { "fieldType": { "type": "string" @@ -141472,6 +141732,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "folderPublicId": { "fieldType": { "type": "string" @@ -141576,6 +141842,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "folderPublicId": { "fieldType": { "type": "string" @@ -141730,6 +142002,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "expectedRevision": { "fieldType": { "type": "number" @@ -142242,6 +142520,12 @@ "args": { "type": "object", "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "expectedRevision": { "fieldType": { "type": "number" @@ -142332,6 +142616,12 @@ }, "optional": true }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, "expectedRevision": { "fieldType": { "type": "number" @@ -166041,7 +166331,14 @@ { "args": { "type": "object", - "value": {} + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } }, "functionType": "Mutation", "identifier": "users.js:ensureCurrentUser", diff --git a/convex/imageAssetLifecycle.test.ts b/convex/imageAssetLifecycle.test.ts new file mode 100644 index 00000000..38b64be8 --- /dev/null +++ b/convex/imageAssetLifecycle.test.ts @@ -0,0 +1,648 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import type { DataModel } from "./_generated/dataModel"; +import cronDefinitions from "./crons"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const addPage = makeFunctionReference<"mutation">("pages:add"); +const deletePage = makeFunctionReference<"mutation">("pages:delete"); +const deleteStrategy = makeFunctionReference<"mutation">("strategies:delete"); +const markStaleImageUploadsDeleted = makeFunctionReference<"mutation">( + "images:markStaleImageUploadsDeleted", +); +const sweepDeletedImageAssets = makeFunctionReference<"action">( + "images:sweepDeletedImageAssets", +); +const completeUpload = makeFunctionReference<"action">("images:completeUpload"); +const getAssetUrl = makeFunctionReference<"query">("images:getAssetUrl"); + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; + +const strategyPublicId = "asset-lifecycle-strategy"; +const pageA = "asset-page-a"; +const pageB = "asset-page-b"; + +function identity() { + return { + issuer: "https://asset-lifecycle.test", + subject: "owner", + tokenIdentifier: "asset-lifecycle|owner", + name: "Asset Owner", + }; +} + +async function createHarness(): Promise<{ + t: RootHarness; + owner: Harness; +}> { + const t = convexTest(schema, modules); + const owner = t.withIdentity(identity()); + await owner.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }); + return { t, owner }; +} + +async function seedStrategy(owner: Harness): Promise { + await owner.mutation(createStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: strategyPublicId, + name: "Asset lifecycle", + mapData: "ascent", + initialPagePublicId: pageA, + initialPageName: "Page 1", + initialPageIsAttack: true, + }); +} + +async function getStrategyAndPages(t: RootHarness) { + return await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .unique(); + if (strategy === null) { + throw new Error("Missing Strategy test row"); + } + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + return { strategy, pages }; + }); +} + +function imagePayload(assetPublicId: string) { + return { + kind: "image" as const, + payloadVersion: 1, + data: { id: assetPublicId, elementType: "image" }, + }; +} + +function lineupPayload(assetPublicId: string) { + return { + kind: "lineupGroup" as const, + payloadVersion: 1, + data: { items: [{ images: [{ id: assetPublicId }] }] }, + }; +} + +function mockR2Deletes(statuses: number[] = [204]) { + let callIndex = 0; + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => { + const status = statuses[Math.min(callIndex, statuses.length - 1)]!; + callIndex += 1; + return new Response(null, { status }); + }, + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +async function allAssets(t: RootHarness) { + return await t.run( + async (ctx) => await ctx.db.query("imageAssets").collect(), + ); +} + +beforeAll(() => { + process.env.R2_ACCOUNT_ID = "asset-lifecycle-account"; + process.env.R2_BUCKET = "asset-lifecycle-bucket"; + process.env.R2_ACCESS_KEY_ID = "asset-lifecycle-access-key"; + process.env.R2_SECRET_ACCESS_KEY = "asset-lifecycle-secret"; + process.env.R2_PUBLIC_BASE_URL = "https://assets.asset-lifecycle.test"; + process.env.R2_S3_ENDPOINT = "https://asset-lifecycle.r2.test"; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("image asset lifecycle", () => { + test("page deletion removes only assets unreferenced by remaining Pages and Lineups", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "Page 2", + sortIndex: 1, + isAttack: false, + }); + const { strategy, pages } = await getStrategyAndPages(t); + const pageAId = pages.find((page) => page.publicId === pageA)?._id; + const pageBId = pages.find((page) => page.publicId === pageB)?._id; + if (pageAId === undefined || pageBId === undefined) { + throw new Error("Missing Page test rows"); + } + + await t.run(async (ctx) => { + const now = Date.now(); + for (const [publicId, objectKey] of [ + ["page-only", "pages/page-only.png"], + ["tombstoned-page-only", "pages/tombstoned-page-only.png"], + ["still-used", "pages/still-used.png"], + ["still-used-by-element", "pages/still-used-by-element.png"], + ] as const) { + await ctx.db.insert("imageAssets", { + publicId, + provider: "r2", + strategyId: strategy._id, + objectKey, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + } + for (const [publicId, assetPublicId] of [ + ["page-only-element", "page-only"], + ["tombstoned-element", "tombstoned-page-only"], + ["shared-element", "still-used"], + ["shared-page-element", "still-used-by-element"], + ] as const) { + await ctx.db.insert("elements", { + publicId, + strategyId: strategy._id, + pageId: pageAId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: imagePayload(assetPublicId), + sortIndex: 0, + revision: 1, + deleted: publicId === "tombstoned-element", + createdAt: now, + updatedAt: now, + }); + } + await ctx.db.insert("lineups", { + publicId: "remaining-lineup", + strategyId: strategy._id, + pageId: pageBId, + payloadKind: "lineupGroup", + payloadVersion: 1, + payload: lineupPayload("still-used"), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("elements", { + publicId: "remaining-image-element", + strategyId: strategy._id, + pageId: pageBId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: imagePayload("still-used-by-element"), + sortIndex: 1, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); + + await owner.mutation(deletePage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + expectedRevision: 1, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + const assets = await allAssets(t); + expect(assets).toMatchObject([ + { publicId: "still-used", uploadStatus: "active" }, + { publicId: "still-used-by-element", uploadStatus: "active" }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: "DELETE" }); + expect(fetchMock.mock.calls.map((call) => String(call[0])).sort()).toEqual( + expect.arrayContaining([ + expect.stringContaining("page-only.png"), + expect.stringContaining("tombstoned-page-only.png"), + ]), + ); + }); + + test("Strategy deletion reclaims exact-owned R2 and Convex assets but preserves ambiguous shared legacy rows", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + const { uniqueStorageId, sharedStorageId } = await t.run(async (ctx) => ({ + uniqueStorageId: await ctx.storage.store(new Blob(["unique"])), + sharedStorageId: await ctx.storage.store(new Blob(["shared"])), + })); + + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("imageAssets", { + publicId: "owned-r2", + provider: "r2", + strategyId: strategy._id, + objectKey: "strategies/owned/delete.png", + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "owned-shared-r2", + provider: "r2", + strategyId: strategy._id, + objectKey: "legacy/shared.png", + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "legacy-shared-r2", + provider: "r2", + objectKey: "legacy/shared.png", + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "legacy-deleted-r2", + provider: "r2", + objectKey: "legacy/ambiguous-deleted.png", + uploadStatus: "deleted", + deletedAt: now, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "owned-convex", + provider: "convex", + strategyId: strategy._id, + storageId: uniqueStorageId, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "owned-shared-convex", + provider: "convex", + strategyId: strategy._id, + storageId: sharedStorageId, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "legacy-shared-convex", + provider: "convex", + storageId: sharedStorageId, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + }); + + await owner.mutation(deleteStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await allAssets(t)).toMatchObject([ + { publicId: "legacy-shared-r2", objectKey: "legacy/shared.png" }, + { + publicId: "legacy-deleted-r2", + objectKey: "legacy/ambiguous-deleted.png", + }, + { publicId: "legacy-shared-convex", storageId: sharedStorageId }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("delete.png"); + await expect( + t.run(async (ctx) => (await ctx.storage.get(uniqueStorageId)) === null), + ).resolves.toBe(true); + await expect( + t.run(async (ctx) => (await ctx.storage.get(sharedStorageId)) !== null), + ).resolves.toBe(true); + }); + + test("R2 failure keeps the tombstone target for an idempotent retry", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes([500, 404]); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + const assetId = await t.run(async (ctx) => { + const now = Date.now(); + return await ctx.db.insert("imageAssets", { + publicId: "retry-r2", + provider: "r2", + strategyId: strategy._id, + objectKey: "retry/keep-target.png", + uploadStatus: "deleted", + deletedAt: now, + createdAt: now, + updatedAt: now, + }); + }); + + await expect( + t.action(sweepDeletedImageAssets, { limit: 1 }), + ).resolves.toMatchObject({ deleted: 0, failed: 1 }); + await expect( + t.run(async (ctx) => await ctx.db.get(assetId)), + ).resolves.toMatchObject({ + uploadStatus: "deleted", + objectKey: "retry/keep-target.png", + }); + + await expect( + t.action(sweepDeletedImageAssets, { limit: 1 }), + ).resolves.toMatchObject({ deleted: 1, failed: 0 }); + await expect( + t.run(async (ctx) => await ctx.db.get(assetId)), + ).resolves.toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); + await t.finishAllScheduledFunctions(vi.runAllTimers); + }); + + test("the last deleted row sharing an R2 key removes the object", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + await t.run(async (ctx) => { + const now = Date.now(); + for (const publicId of ["duplicate-a", "duplicate-b"]) { + await ctx.db.insert("imageAssets", { + publicId, + provider: "r2", + strategyId: strategy._id, + objectKey: "duplicates/shared-deleted.png", + uploadStatus: "deleted", + deletedAt: now, + createdAt: now, + updatedAt: now, + }); + } + }); + + await expect( + t.action(sweepDeletedImageAssets, { limit: 2 }), + ).resolves.toMatchObject({ deleted: 2, failed: 0 }); + expect(await allAssets(t)).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + "shared-deleted.png", + ); + await t.finishAllScheduledFunctions(vi.runAllTimers); + }); + + test("overlapping cleanup actions claim one R2 tombstone once", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("imageAssets", { + publicId: "single-claim", + provider: "r2", + strategyId: strategy._id, + objectKey: "claims/single.png", + uploadStatus: "deleted", + deletedAt: now, + createdAt: now, + updatedAt: now, + }); + }); + + const results = (await Promise.all([ + t.action(sweepDeletedImageAssets, { limit: 1 }), + t.action(sweepDeletedImageAssets, { limit: 1 }), + ])) as Array<{ deleted: number; failed: number }>; + + expect(results.reduce((total, result) => total + result.deleted, 0)).toBe( + 1, + ); + expect(results.reduce((total, result) => total + result.failed, 0)).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(await allAssets(t)).toEqual([]); + await t.finishAllScheduledFunctions(vi.runAllTimers); + }); + + test("the hourly path releases a stranded claim and completes its deletion", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + const now = Date.now(); + await t.run(async (ctx) => { + await ctx.db.insert("imageAssets", { + publicId: "stranded-claim", + provider: "r2", + strategyId: strategy._id, + objectKey: "claims/stranded.png", + uploadStatus: "deleted", + deletedAt: now - 60 * 60 * 1000, + cleanupClaimedAt: now - 16 * 60 * 1000, + createdAt: now - 60 * 60 * 1000, + updatedAt: now - 60 * 60 * 1000, + }); + }); + + await expect( + t.mutation(markStaleImageUploadsDeleted, {}), + ).resolves.toMatchObject({ deleted: 0, released: 1 }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await allAssets(t)).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("legacy reads survive while completion inserts an exact-owned replacement", async () => { + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy, pages } = await getStrategyAndPages(t); + const pageId = pages.find((page) => page.publicId === pageA)?._id; + if (pageId === undefined) { + throw new Error("Missing Page test row"); + } + const { legacyStorageId, replacementStorageId } = await t.run( + async (ctx) => ({ + legacyStorageId: await ctx.storage.store(new Blob(["legacy"])), + replacementStorageId: await ctx.storage.store( + new Blob(["replacement"]), + ), + }), + ); + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("imageAssets", { + publicId: "legacy-readable", + provider: "convex", + storageId: legacyStorageId, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("elements", { + publicId: "legacy-image-element", + strategyId: strategy._id, + pageId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: imagePayload("legacy-readable"), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); + + const legacyResult = (await owner.query(getAssetUrl, { + strategyPublicId, + assetPublicId: "legacy-readable", + })) as { url: string | null }; + expect(legacyResult.url).toMatch( + /^https:\/\/some-deployment\.convex\.cloud\//, + ); + await owner.action(completeUpload, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + assetPublicId: "legacy-readable", + provider: "convex", + storageId: replacementStorageId, + fileExtension: ".png", + mimeType: "image/png", + }); + + const assets = await allAssets(t); + expect( + assets.find((asset) => asset.strategyId === undefined), + ).toMatchObject({ + publicId: "legacy-readable", + storageId: legacyStorageId, + }); + expect( + assets.find((asset) => asset.strategyId === strategy._id), + ).toMatchObject({ + publicId: "legacy-readable", + storageId: replacementStorageId, + }); + const replacementResult = (await owner.query(getAssetUrl, { + strategyPublicId, + assetPublicId: "legacy-readable", + })) as { url: string | null }; + expect(replacementResult.url).toMatch( + /^https:\/\/some-deployment\.convex\.cloud\//, + ); + expect(replacementResult.url).not.toBe(legacyResult.url); + }); + + test("the cron path marks stale owned uploads without user auth and leaves ambiguous legacy rows", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + const staleAt = Date.now() - 48 * 60 * 60 * 1000; + await t.run(async (ctx) => { + await ctx.db.insert("imageAssets", { + publicId: "stale-owned", + provider: "r2", + strategyId: strategy._id, + objectKey: "stale/owned.png", + uploadStatus: "pending", + createdAt: staleAt, + updatedAt: staleAt, + }); + await ctx.db.insert("imageAssets", { + publicId: "stale-legacy", + provider: "r2", + objectKey: "stale/legacy.png", + uploadStatus: "failed", + createdAt: staleAt, + updatedAt: staleAt, + }); + }); + + expect( + cronDefinitions.crons["mark-stale-image-uploads-deleted"], + ).toMatchObject({ + name: "images:markStaleImageUploadsDeleted", + schedule: { hours: 1, type: "interval" }, + }); + await expect( + t.mutation(markStaleImageUploadsDeleted, { + staleBefore: Date.now() - 24 * 60 * 60 * 1000, + }), + ).resolves.toMatchObject({ deleted: 1 }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await allAssets(t)).toMatchObject([ + { publicId: "stale-legacy", uploadStatus: "failed" }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("Strategy cleanup continues through bounded database and external deletion batches", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + await t.run(async (ctx) => { + const now = Date.now(); + for (let index = 0; index < 126; index += 1) { + await ctx.db.insert("imageAssets", { + publicId: `bounded-${index}`, + provider: "r2", + strategyId: strategy._id, + objectKey: `bounded/${index}.png`, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + } + }); + + await owner.mutation(deleteStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await allAssets(t)).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(126); + }, 30_000); +}); diff --git a/convex/images.ts b/convex/images.ts index 8a6e85bb..3f742bb9 100644 --- a/convex/images.ts +++ b/convex/images.ts @@ -8,7 +8,6 @@ import { getViewerAssetForStrategy, inferProvider, inferUploadStatus, - isVisibleAsset, serializeAssetForViewer, type Provider, type UploadStatus, @@ -48,6 +47,11 @@ import { imageProviderValidator, okResultValidator, } from "./lib/publicValidators"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { makeFunctionReference } from "convex/server"; type AnyCtx = MutationCtx | QueryCtx; @@ -55,9 +59,41 @@ type DeletionTarget = { assetId: Id<"imageAssets">; provider: Provider; objectKey: string | null; + sharedTarget: boolean; }; const maxDeletionBatch = 100; +const physicalDeletionBatch = 25; +const pageAssetIdBatch = 50; +const staleUploadAgeMs = 24 * 60 * 60 * 1000; +const staleDeletionClaimAgeMs = 15 * 60 * 1000; +const deletionRetryDelayMs = 60 * 1000; + +export const markDeletedPageImageAssetsRef = makeFunctionReference<"mutation">( + "images:markDeletedPageImageAssets", +); +export const markDeletedStrategyImageAssetsRef = + makeFunctionReference<"mutation">("images:markDeletedStrategyImageAssets"); +export const markStaleImageUploadsDeletedRef = + makeFunctionReference<"mutation">("images:markStaleImageUploadsDeleted"); +export const sweepDeletedImageAssetsRef = makeFunctionReference<"action">( + "images:sweepDeletedImageAssets", +); +const claimDeletedImageAssetsRef = makeFunctionReference<"mutation">( + "images:claimDeletedImageAssets", +); +const getDeletedImageAssetTargetRef = makeFunctionReference<"query">( + "images:getDeletedImageAssetTarget", +); +const finalizeDeletedImageAssetRef = makeFunctionReference<"mutation">( + "images:finalizeDeletedImageAsset", +); +const scheduleDeletedImageAssetSweepRef = makeFunctionReference<"mutation">( + "images:scheduleDeletedImageAssetSweep", +); +const releaseImageAssetDeletionClaimsRef = makeFunctionReference<"mutation">( + "images:releaseImageAssetDeletionClaims", +); function createUploadAttemptPublicId(): string { return crypto.randomUUID(); @@ -66,6 +102,7 @@ function createUploadAttemptPublicId(): string { async function collectReferencedAssetIdsForStrategy( ctx: AnyCtx, strategyId: Doc<"strategies">["_id"], + excludedPageId?: Id<"pages">, ): Promise> { const assetIds = new Set(); @@ -73,7 +110,11 @@ async function collectReferencedAssetIdsForStrategy( .query("elements") .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)); for await (const element of elementQuery) { - if (element.deleted || element.elementType !== "image") { + if ( + element.deleted || + element.pageId === excludedPageId || + element.elementType !== "image" + ) { continue; } const assetId = collectAssetIdFromElementPayload(element.payload); @@ -86,7 +127,7 @@ async function collectReferencedAssetIdsForStrategy( .query("lineups") .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)); for await (const lineup of lineupQuery) { - if (lineup.deleted) { + if (lineup.deleted || lineup.pageId === excludedPageId) { continue; } for (const assetId of collectAssetIdsFromLineupPayload(lineup.payload)) { @@ -112,20 +153,7 @@ async function getDeletionCandidateForStrategy( const ownedCandidate = strategyCandidates.find((asset) => inferUploadStatus(asset) !== "deleted") ?? null; - if (ownedCandidate !== null) { - return ownedCandidate; - } - - const legacyCandidates = await ctx.db - .query("imageAssets") - .withIndex("by_publicId", (q) => q.eq("publicId", assetPublicId)) - .order("desc") - .take(20); - return ( - legacyCandidates.find( - (asset) => asset.strategyId === undefined && isVisibleAsset(asset), - ) ?? null - ); + return ownedCandidate; } async function strategyReferencesAsset( @@ -140,31 +168,55 @@ async function strategyReferencesAsset( return referencedAssetIds.has(assetPublicId); } -function deletionTargetForAsset(asset: Doc<"imageAssets">): DeletionTarget { - return { - assetId: asset._id, - provider: inferProvider(asset), - objectKey: asset.objectKey ?? null, - }; -} - async function markImageAssetDeleted( ctx: MutationCtx, asset: Doc<"imageAssets">, now: number, ): Promise { - if (asset.storageId !== undefined) { - await ctx.storage.delete(asset.storageId); - } await ctx.db.patch(asset._id, { uploadStatus: "deleted", deletedAt: now, + cleanupClaimedAt: undefined, updatedAt: now, }); } +async function schedulePhysicalDeletion( + ctx: MutationCtx, + delayMs = 0, +): Promise { + await ctx.scheduler.runAfter(delayMs, sweepDeletedImageAssetsRef, {}); +} + +function chunks(values: T[], size: number): T[][] { + const result: T[][] = []; + for (let index = 0; index < values.length; index += size) { + result.push(values.slice(index, index + size)); + } + return result; +} + +export async function captureDeletedPageImageAssets( + ctx: MutationCtx, + args: { + strategyId: Id<"strategies">; + pageId: Id<"pages">; + assetPublicIds: Iterable; + }, +): Promise { + const assetPublicIds = [...new Set(args.assetPublicIds)]; + for (const assetIdChunk of chunks(assetPublicIds, pageAssetIdBatch)) { + await ctx.scheduler.runAfter(0, markDeletedPageImageAssetsRef, { + strategyId: args.strategyId, + pageId: args.pageId, + assetPublicIds: assetIdChunk, + }); + } +} + export const generateUploadUrl = action({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), assetPublicId: v.string(), mimeType: v.string(), @@ -183,6 +235,7 @@ export const generateUploadUrl = action({ maxBytes: v.number(), }), handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const config = getR2Config(); const validated = validateImageUploadMetadata({ fileExtension: args.fileExtension, @@ -283,6 +336,7 @@ export const createR2UploadIntent = internalMutation({ export const completeUpload = action({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), assetPublicId: v.string(), provider: v.optional(imageProviderValidator), @@ -308,6 +362,7 @@ export const completeUpload = action({ ctx, args, ) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); if (args.storageId !== undefined || args.provider === "convex") { await ctx.runMutation(internal.images.completeLegacyUpload, { strategyPublicId: args.strategyPublicId, @@ -384,24 +439,17 @@ export const completeUpload = action({ throw invalidPayloadError("Uploaded image failed size or MIME validation."); } - const result: { ok: true; replaced: DeletionTarget[] } = - await ctx.runMutation(internal.images.markR2UploadActive, { - strategyPublicId: args.strategyPublicId, - assetPublicId: args.assetPublicId, - uploadId: intent.uploadId, - byteSize: actualByteSize, - etag: metadata.etag ?? args.etag, - mimeType: actualMimeType, - fileExtension: args.fileExtension ?? intent.fileExtension, - width: args.width, - height: args.height, - }); - - for (const target of result.replaced) { - if (target.provider === "r2" && target.objectKey !== null) { - await deleteR2Object(config, target.objectKey); - } - } + await ctx.runMutation(internal.images.markR2UploadActive, { + strategyPublicId: args.strategyPublicId, + assetPublicId: args.assetPublicId, + uploadId: intent.uploadId, + byteSize: actualByteSize, + etag: metadata.etag ?? args.etag, + mimeType: actualMimeType, + fileExtension: args.fileExtension ?? intent.fileExtension, + width: args.width, + height: args.height, + }); return { ok: true as const, @@ -432,7 +480,8 @@ export const getR2UploadIntentForCompletion = internalQuery({ asset.strategyId !== strategy._id || asset.publicId !== args.assetPublicId || inferProvider(asset) !== "r2" || - asset.objectKey === undefined + asset.objectKey === undefined || + inferUploadStatus(asset) === "deleted" ) { throw errorWithCode("UPLOAD_INTENT_NOT_FOUND", "Upload intent not found."); } @@ -476,7 +525,8 @@ export const markR2UploadActive = internalMutation({ asset.strategyId !== strategy._id || asset.publicId !== args.assetPublicId || inferProvider(asset) !== "r2" || - asset.objectKey === undefined + asset.objectKey === undefined || + inferUploadStatus(asset) === "deleted" ) { throw errorWithCode("UPLOAD_INTENT_NOT_FOUND", "Upload intent not found."); } @@ -503,15 +553,18 @@ export const markR2UploadActive = internalMutation({ .eq("uploadStatus", "active"), ) .take(20); - const replaced: DeletionTarget[] = []; + let replaced = 0; for (const olderAsset of olderActiveAssets) { if (olderAsset._id === asset._id) { continue; } - replaced.push(deletionTargetForAsset(olderAsset)); await markImageAssetDeleted(ctx, olderAsset, now); + replaced += 1; } + if (replaced > 0) { + await schedulePhysicalDeletion(ctx); + } return { ok: true as const, replaced }; }, }); @@ -581,7 +634,8 @@ export const completeLegacyUpload = internalMutation({ }); if ( previousStorageId !== undefined && - previousStorageId !== args.storageId + previousStorageId !== args.storageId && + !(await hasSharedDeletionTarget(ctx, existing)) ) { await ctx.storage.delete(previousStorageId); } @@ -675,20 +729,18 @@ export const getAssetUrl = query({ export const deleteAssetRef = action({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), assetPublicId: v.string(), }, returns: okResultValidator, handler: async (ctx, args) => { - const target: DeletionTarget = await ctx.runQuery( + assertSupportedCloudProtocol(args.clientProtocolVersion); + const target: { assetId: Id<"imageAssets"> } = await ctx.runQuery( internal.images.getAssetDeletionTarget, args, ); - if (target.provider === "r2" && target.objectKey !== null) { - await deleteR2Object(getR2Config(), target.objectKey); - } - await ctx.runMutation(internal.images.markDeletedAssetRefsForStrategy, { strategyPublicId: args.strategyPublicId, assetIds: [target.assetId], @@ -715,14 +767,11 @@ export const getAssetDeletionTarget = internalQuery({ throw notFoundError("Asset", args.assetPublicId); } - if ( - asset.strategyId === undefined && - !(await strategyReferencesAsset(ctx, strategy._id, args.assetPublicId)) - ) { - throw notFoundError("Asset", args.assetPublicId); + if (await strategyReferencesAsset(ctx, strategy._id, args.assetPublicId)) { + throw conflictError("Asset is still referenced by this Strategy."); } - return deletionTargetForAsset(asset); + return { assetId: asset._id }; }, }); @@ -737,132 +786,417 @@ export const markDeletedAssetRefsForStrategy = internalMutation({ const now = Date.now(); let deleted = 0; + let shouldSweep = false; + const referencedAssetIds = await collectReferencedAssetIdsForStrategy( + ctx, + strategy._id, + ); for (const assetId of args.assetIds.slice(0, maxDeletionBatch)) { const asset = await ctx.db.get(assetId); - if (asset === null || inferUploadStatus(asset) === "deleted") { + if (asset === null) { continue; } - if (asset.strategyId !== undefined && asset.strategyId !== strategy._id) { + if ( + asset.strategyId !== strategy._id || + referencedAssetIds.has(asset.publicId) + ) { continue; } - await markImageAssetDeleted(ctx, asset, now); - deleted += 1; + shouldSweep = true; + if (inferUploadStatus(asset) !== "deleted") { + await markImageAssetDeleted(ctx, asset, now); + deleted += 1; + } + } + if (shouldSweep) { + await schedulePhysicalDeletion(ctx); } return { ok: true, deleted }; }, }); -export const listPotentiallyStale = internalQuery({ +export const markDeletedPageImageAssets = internalMutation({ args: { - strategyPublicId: v.string(), - limit: v.optional(v.number()), + strategyId: v.id("strategies"), + pageId: v.id("pages"), + assetPublicIds: v.array(v.string()), }, handler: async (ctx, args) => { - const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); - await assertStrategyRole(ctx, strategy, "editor"); - const limit = Math.max(1, Math.min(args.limit ?? 200, 500)); - - const referencedAssetIds = await collectReferencedAssetIdsForStrategy( + const candidateIds = new Set( + args.assetPublicIds.slice(0, pageAssetIdBatch), + ); + const remainingReferences = await collectReferencedAssetIdsForStrategy( ctx, - strategy._id, + args.strategyId, + args.pageId, ); + for (const referencedId of remainingReferences) { + candidateIds.delete(referencedId); + } + + const assets: Doc<"imageAssets">[] = []; + for (const assetPublicId of candidateIds) { + const remainingSlots = maxDeletionBatch - assets.length; + if (remainingSlots <= 0) { + break; + } + const matches = await ctx.db + .query("imageAssets") + .withIndex("by_strategyId_and_publicId", (q) => + q.eq("strategyId", args.strategyId).eq("publicId", assetPublicId), + ) + .filter((q) => q.neq(q.field("uploadStatus"), "deleted")) + .take(remainingSlots); + assets.push(...matches); + } + + const now = Date.now(); + for (const asset of assets) { + await markImageAssetDeleted(ctx, asset, now); + } + if (assets.length > 0) { + await schedulePhysicalDeletion(ctx); + } + if (assets.length === maxDeletionBatch) { + await ctx.scheduler.runAfter(0, markDeletedPageImageAssetsRef, args); + } + return { ok: true as const, deleted: assets.length }; + }, +}); + +export const markDeletedStrategyImageAssets = internalMutation({ + args: { + strategyId: v.id("strategies"), + }, + handler: async (ctx, args) => { const assets = await ctx.db .query("imageAssets") - .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) - .order("desc") + .withIndex("by_strategyId", (q) => q.eq("strategyId", args.strategyId)) + .filter((q) => q.neq(q.field("uploadStatus"), "deleted")) + .take(maxDeletionBatch); + const now = Date.now(); + for (const asset of assets) { + await markImageAssetDeleted(ctx, asset, now); + } + if (assets.length > 0) { + await schedulePhysicalDeletion(ctx); + } + if (assets.length === maxDeletionBatch) { + await ctx.scheduler.runAfter(0, markDeletedStrategyImageAssetsRef, args); + } + return { ok: true as const, deleted: assets.length }; + }, +}); + +export const markStaleImageUploadsDeleted = internalMutation({ + args: { + staleBefore: v.optional(v.number()), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const staleBefore = args.staleBefore ?? Date.now() - staleUploadAgeMs; + const limit = Math.max( + 1, + Math.min(args.limit ?? maxDeletionBatch, maxDeletionBatch), + ); + const stuckClaims = await ctx.db + .query("imageAssets") + .withIndex("by_uploadStatus_and_updatedAt", (q) => + q.eq("uploadStatus", "deleted"), + ) + .filter((q) => + q.and( + q.neq(q.field("strategyId"), undefined), + q.neq(q.field("cleanupClaimedAt"), undefined), + q.lte( + q.field("cleanupClaimedAt"), + Date.now() - staleDeletionClaimAgeMs, + ), + ), + ) .take(limit); + for (const asset of stuckClaims) { + await ctx.db.patch(asset._id, { cleanupClaimedAt: undefined }); + } - const candidates = assets.filter((asset) => { - const status = inferUploadStatus(asset); - if (status === "deleted") { - return false; + const assets: Doc<"imageAssets">[] = []; + for (const status of ["pending", "failed"] as UploadStatus[]) { + const remainingSlots = limit - stuckClaims.length - assets.length; + if (remainingSlots <= 0) { + break; } - if (status === "pending" || status === "failed") { - return true; + const matches = await ctx.db + .query("imageAssets") + .withIndex("by_uploadStatus_and_updatedAt", (q) => + q.eq("uploadStatus", status).lte("updatedAt", staleBefore), + ) + .filter((q) => q.neq(q.field("strategyId"), undefined)) + .take(remainingSlots); + assets.push(...matches); } - return !referencedAssetIds.has(asset.publicId); + + const now = Date.now(); + for (const asset of assets) { + await markImageAssetDeleted(ctx, asset, now); + } + if (assets.length > 0 || stuckClaims.length > 0) { + await schedulePhysicalDeletion(ctx); + } + if (assets.length + stuckClaims.length === limit) { + await ctx.scheduler.runAfter(0, markStaleImageUploadsDeletedRef, { + staleBefore, + limit, + }); + } + return { + ok: true as const, + deleted: assets.length, + released: stuckClaims.length, + }; + }, }); - return await Promise.all( - candidates.map((asset) => serializeAssetForViewer(ctx, asset)), +async function hasSharedDeletionTarget( + ctx: QueryCtx | MutationCtx, + asset: Doc<"imageAssets">, +): Promise { + if (inferProvider(asset) === "r2") { + if (asset.objectKey === undefined) { + return false; + } + const matches = await ctx.db + .query("imageAssets") + .withIndex("by_objectKey", (q) => q.eq("objectKey", asset.objectKey)) + .take(2); + return matches.some((candidate) => candidate._id !== asset._id); + } + if (asset.storageId === undefined) { + return false; + } + const matches = await ctx.db + .query("imageAssets") + .withIndex("by_storageId", (q) => q.eq("storageId", asset.storageId)) + .take(2); + return matches.some((candidate) => candidate._id !== asset._id); +} + +export const claimDeletedImageAssets = internalMutation({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = Math.max( + 1, + Math.min(args.limit ?? physicalDeletionBatch, physicalDeletionBatch), ); + const assets = await ctx.db + .query("imageAssets") + .withIndex("by_uploadStatus_and_updatedAt", (q) => + q.eq("uploadStatus", "deleted"), + ) + .filter((q) => + q.and( + q.neq(q.field("strategyId"), undefined), + q.eq(q.field("cleanupClaimedAt"), undefined), + ), + ) + .take(limit); + const now = Date.now(); + for (const asset of assets) { + await ctx.db.patch(asset._id, { cleanupClaimedAt: now }); + } + return assets.map((asset) => asset._id); }, }); -export const sweepStaleUploadsForStrategy = internalAction({ +export const getDeletedImageAssetTarget = internalQuery({ args: { - strategyPublicId: v.string(), - staleBefore: v.number(), - limit: v.optional(v.number()), + assetId: v.id("imageAssets"), }, - handler: async ( + handler: async (ctx, args): Promise => { + const asset = await ctx.db.get(args.assetId); + if ( + asset === null || + inferUploadStatus(asset) !== "deleted" || + asset.cleanupClaimedAt === undefined + ) { + return null; + } + return { + assetId: asset._id, + provider: inferProvider(asset), + objectKey: asset.objectKey ?? null, + sharedTarget: await hasSharedDeletionTarget(ctx, asset), + }; + }, +}); + +export const finalizeDeletedImageAsset = internalMutation({ + args: { + assetId: v.id("imageAssets"), + r2ObjectDeleted: v.boolean(), + }, + handler: async (ctx, args) => { + const asset = await ctx.db.get(args.assetId); + if (asset === null) { + return { ok: true as const, finalized: true }; + } + if (inferUploadStatus(asset) !== "deleted") { + return { ok: true as const, finalized: false }; + } + + const sharedTarget = await hasSharedDeletionTarget(ctx, asset); + if (inferProvider(asset) === "r2") { + if ( + asset.objectKey !== undefined && + !sharedTarget && + !args.r2ObjectDeleted + ) { + return { ok: true as const, finalized: false }; + } + } else if (asset.storageId !== undefined && !sharedTarget) { + await ctx.storage.delete(asset.storageId); + } + await ctx.db.delete(asset._id); + return { ok: true as const, finalized: true }; + }, +}); + +export const scheduleDeletedImageAssetSweep = internalMutation({ + args: { + delayMs: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const delayMs = Math.max(0, Math.min(args.delayMs ?? 0, 60 * 60 * 1000)); + await schedulePhysicalDeletion(ctx, delayMs); + return { ok: true as const }; + }, +}); + +export const releaseImageAssetDeletionClaims = internalMutation({ + args: { + assetIds: v.array(v.id("imageAssets")), + retryAfterMs: v.optional(v.number()), + }, + handler: async (ctx, args) => { + for (const assetId of args.assetIds.slice(0, physicalDeletionBatch)) { + const asset = await ctx.db.get(assetId); + if ( + asset !== null && + inferUploadStatus(asset) === "deleted" && + asset.cleanupClaimedAt !== undefined + ) { + await ctx.db.patch(asset._id, { cleanupClaimedAt: undefined }); + } + } + await schedulePhysicalDeletion( ctx, - args, - ): Promise<{ - ok: true; - deleted: number; - }> => { - const targets: DeletionTarget[] = await ctx.runQuery( - internal.images.listStaleUploadDeletionTargets, - args, + Math.max(0, args.retryAfterMs ?? deletionRetryDelayMs), ); - const config = targets.some( - (target) => target.provider === "r2" && target.objectKey !== null, - ) - ? getR2Config() - : null; + return { ok: true as const }; + }, +}); - for (const target of targets) { +export const sweepDeletedImageAssets = internalAction({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = Math.max( + 1, + Math.min(args.limit ?? physicalDeletionBatch, physicalDeletionBatch), + ); + const assetIds: Id<"imageAssets">[] = await ctx.runMutation( + claimDeletedImageAssetsRef, + { limit }, + ); + let deleted = 0; + let failed = 0; + const failedAssetIds: Id<"imageAssets">[] = []; + let config: ReturnType | null = null; + + for (const assetId of assetIds) { + try { + const target: DeletionTarget | null = await ctx.runQuery( + getDeletedImageAssetTargetRef, + { assetId }, + ); + if (target === null) { + continue; + } + let r2ObjectDeleted = false; if ( - config !== null && target.provider === "r2" && - target.objectKey !== null + target.objectKey !== null && + !target.sharedTarget ) { + config ??= getR2Config(); await deleteR2Object(config, target.objectKey); + r2ObjectDeleted = true; + } + const result: { finalized: boolean } = await ctx.runMutation( + finalizeDeletedImageAssetRef, + { assetId, r2ObjectDeleted }, + ); + if (result.finalized) { + deleted += 1; + } else { + failed += 1; + failedAssetIds.push(assetId); + } + } catch { + failed += 1; + failedAssetIds.push(assetId); } } - const result: { ok: boolean; deleted: number } = await ctx.runMutation( - internal.images.markDeletedAssetRefsForStrategy, - { - strategyPublicId: args.strategyPublicId, - assetIds: targets.map((target) => target.assetId), - }, - ); - return { ok: true, deleted: result.deleted }; + if (failedAssetIds.length > 0) { + await ctx.runMutation(releaseImageAssetDeletionClaimsRef, { + assetIds: failedAssetIds, + retryAfterMs: deletionRetryDelayMs, + }); + } else if (assetIds.length === limit) { + await ctx.runMutation(scheduleDeletedImageAssetSweepRef, { + delayMs: 0, + }); + } + return { ok: true as const, deleted, failed }; }, }); -export const listStaleUploadDeletionTargets = internalQuery({ +export const listPotentiallyStale = internalQuery({ args: { strategyPublicId: v.string(), - staleBefore: v.number(), limit: v.optional(v.number()), }, handler: async (ctx, args) => { const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); - const limit = Math.max(1, Math.min(args.limit ?? 50, maxDeletionBatch)); + const limit = Math.max(1, Math.min(args.limit ?? 200, 500)); - const targets: DeletionTarget[] = []; - for (const status of ["pending", "failed"] as UploadStatus[]) { - const candidates = await ctx.db + const referencedAssetIds = await collectReferencedAssetIdsForStrategy( + ctx, + strategy._id, + ); + const assets = await ctx.db .query("imageAssets") - .withIndex("by_strategyId_and_uploadStatus_and_updatedAt", (q) => - q - .eq("strategyId", strategy._id) - .eq("uploadStatus", status) - .lte("updatedAt", args.staleBefore), - ) - .take(limit - targets.length); - for (const asset of candidates) { - targets.push(deletionTargetForAsset(asset)); - } - if (targets.length >= limit) { - break; + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .order("desc") + .take(limit); + + const candidates = assets.filter((asset) => { + const status = inferUploadStatus(asset); + if (status === "deleted") { + return false; } + if (status === "pending" || status === "failed") { + return true; } + return !referencedAssetIds.has(asset.publicId); + }); - return targets; + return await Promise.all( + candidates.map((asset) => serializeAssetForViewer(ctx, asset)), + ); }, }); diff --git a/convex/invites.ts b/convex/invites.ts index 618d8865..f908b916 100644 --- a/convex/invites.ts +++ b/convex/invites.ts @@ -8,6 +8,10 @@ import { type CollaboratorRole, } from "./lib/auth"; import { getStrategyByPublicId } from "./lib/entities"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; import { forbiddenError, invalidOpError, @@ -105,6 +109,7 @@ export const get = query({ export const create = mutation({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), token: v.string(), role: v.union(v.literal("editor"), v.literal("viewer")), @@ -112,6 +117,7 @@ export const create = mutation({ }, returns: okResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); const { user, role } = await assertStrategyRole(ctx, strategy, "owner"); if (role !== "owner") { @@ -135,6 +141,7 @@ export const create = mutation({ export const redeem = mutation({ args: { + ...cloudProtocolArgs, token: v.string(), }, returns: v.object({ @@ -143,6 +150,7 @@ export const redeem = mutation({ role: accessRoleValidator, }), handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const user = await requireCurrentUser(ctx); const invite = await ctx.db .query("inviteTokens") @@ -213,11 +221,13 @@ export const redeem = mutation({ export const revoke = mutation({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), token: v.string(), }, returns: okResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); const { role } = await assertStrategyRole(ctx, strategy, "owner"); if (role !== "owner") { diff --git a/convex/lib/cloudProtocol.ts b/convex/lib/cloudProtocol.ts index 453ef977..80d8ef24 100644 --- a/convex/lib/cloudProtocol.ts +++ b/convex/lib/cloudProtocol.ts @@ -1,9 +1,42 @@ import { clientUpgradeRequiredError } from "./errors"; +import { v } from "convex/values"; export const CURRENT_CLOUD_PROTOCOL_VERSION = 3; +export const MAX_CLOUD_OPERATION_BYTES = 900 * 1024; +export const MAX_CLOUD_ARRAY_ENTRIES = 8_000; +export const CLOUD_OPERATION_TOO_LARGE_MESSAGE = + "This saved change is too large for cloud sync. It remains saved on this device."; + +export const cloudProtocolArgs = { + clientProtocolVersion: v.number(), +} as const; export function assertSupportedCloudProtocol(clientProtocolVersion: number): void { if (clientProtocolVersion !== CURRENT_CLOUD_PROTOCOL_VERSION) { throw clientUpgradeRequiredError(); } } + +export function serializedConvexValueUtf8Bytes(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; +} + +export function cloudOperationExceedsPolicy(value: unknown): boolean { + if (serializedConvexValueUtf8Bytes(value) > MAX_CLOUD_OPERATION_BYTES) { + return true; + } + return valueExceedsArrayPolicy(value); +} + +function valueExceedsArrayPolicy(value: unknown): boolean { + if (Array.isArray(value)) { + return ( + value.length > MAX_CLOUD_ARRAY_ENTRIES || + value.some(valueExceedsArrayPolicy) + ); + } + if (value !== null && typeof value === "object") { + return Object.values(value).some(valueExceedsArrayPolicy); + } + return false; +} diff --git a/convex/maintenance.ts b/convex/maintenance.ts index a8339005..c27839a0 100644 --- a/convex/maintenance.ts +++ b/convex/maintenance.ts @@ -1,6 +1,11 @@ import { makeFunctionReference } from "convex/server"; import { internalMutation } from "./_generated/server"; import { v } from "convex/values"; +import { + collectAssetIdFromElementPayload, + collectAssetIdsFromLineupPayload, +} from "./lib/imageAssets"; +import { captureDeletedPageImageAssets } from "./images"; const MAINTENANCE_BATCH_SIZE = 200; const DAYS_30_MS = 30 * 24 * 60 * 60 * 1000; @@ -20,16 +25,13 @@ export const purgeOldTombstonesRef = makeFunctionReference<"mutation">( export const purgeDeletedPageOrphans = internalMutation({ args: { pageId: v.id("pages"), + strategyId: v.id("strategies"), }, handler: async (ctx, args) => { const elements = await ctx.db .query("elements") .withIndex("by_pageId", (q) => q.eq("pageId", args.pageId)) .take(MAINTENANCE_BATCH_SIZE); - for (const element of elements) { - await ctx.db.delete(element._id); - } - const remainingSlots = MAINTENANCE_BATCH_SIZE - elements.length; const lineups = remainingSlots > 0 @@ -38,6 +40,30 @@ export const purgeDeletedPageOrphans = internalMutation({ .withIndex("by_pageId", (q) => q.eq("pageId", args.pageId)) .take(remainingSlots) : []; + + const assetPublicIds = new Set(); + for (const element of elements) { + const assetPublicId = collectAssetIdFromElementPayload(element.payload); + if (assetPublicId !== null) { + assetPublicIds.add(assetPublicId); + } + } + for (const lineup of lineups) { + for (const assetPublicId of collectAssetIdsFromLineupPayload( + lineup.payload, + )) { + assetPublicIds.add(assetPublicId); + } + } + await captureDeletedPageImageAssets(ctx, { + strategyId: args.strategyId, + pageId: args.pageId, + assetPublicIds, + }); + + for (const element of elements) { + await ctx.db.delete(element._id); + } for (const lineup of lineups) { await ctx.db.delete(lineup._id); } @@ -50,7 +76,7 @@ export const purgeDeletedPageOrphans = internalMutation({ await ctx.scheduler.runAfter( 0, purgeDeletedPageOrphansRef, - { pageId: args.pageId }, + { pageId: args.pageId, strategyId: args.strategyId }, ); } }, diff --git a/convex/ops.ts b/convex/ops.ts index a86470af..5de8952e 100644 --- a/convex/ops.ts +++ b/convex/ops.ts @@ -14,7 +14,12 @@ import { strategyOpValidator, type StrategyOp as WireStrategyOp, } from "./lib/opTypes"; -import { assertSupportedCloudProtocol } from "./lib/cloudProtocol"; +import { + assertSupportedCloudProtocol, + CLOUD_OPERATION_TOO_LARGE_MESSAGE, + cloudOperationExceedsPolicy, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; import { valuesEqual } from "./lib/canonicalValues"; import { errorWithCode, invalidPayloadError } from "./lib/errors"; import { purgeDeletedPageOrphansRef } from "./maintenance"; @@ -775,6 +780,7 @@ async function applyPageOp( await ctx.db.delete(existing._id); await ctx.scheduler.runAfter(0, purgeDeletedPageOrphansRef, { pageId: existing._id, + strategyId: strategy._id, }); const now = Date.now(); const remaining = sortByNumberField( @@ -1305,9 +1311,9 @@ async function applyLineupOp( export const applyBatch = mutation({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), clientId: v.string(), - clientProtocolVersion: v.number(), ops: v.array(strategyOpValidator), }, returns: applyBatchResultValidator, @@ -1371,38 +1377,47 @@ export const applyBatch = mutation({ } let result: OperationResult; - try { - if (op.entityType === "strategy") { - const applied = await applyStrategyOp(ctx, strategy, op); - strategy = applied.strategy; - result = applied.result; - } else if (op.entityType === "page") { - const applied = await applyPageOp(ctx, strategy, op); - strategy = applied.strategy; - result = applied.result; - } else if (op.entityType === "pageContent") { - result = await applyPageContentOp(ctx, strategy, op); - } else if (op.entityType === "element") { - result = await applyElementOp(ctx, strategy, op); - } else { - result = await applyLineupOp(ctx, strategy, op); - } - } catch (error) { - if (!(error instanceof ConvexError)) throw error; - const rawCode = - typeof error.data?.code === "string" - ? error.data.code - : "INTERNAL_ERROR"; - const message = - typeof error.data?.message === "string" - ? error.data.message - : error.message; + if (cloudOperationExceedsPolicy(rawOp)) { result = { status: "failed", - code: rawCode, - rawCode, - message, + code: "INVALID_PAYLOAD", + rawCode: "INVALID_PAYLOAD", + message: CLOUD_OPERATION_TOO_LARGE_MESSAGE, }; + } else { + try { + if (op.entityType === "strategy") { + const applied = await applyStrategyOp(ctx, strategy, op); + strategy = applied.strategy; + result = applied.result; + } else if (op.entityType === "page") { + const applied = await applyPageOp(ctx, strategy, op); + strategy = applied.strategy; + result = applied.result; + } else if (op.entityType === "pageContent") { + result = await applyPageContentOp(ctx, strategy, op); + } else if (op.entityType === "element") { + result = await applyElementOp(ctx, strategy, op); + } else { + result = await applyLineupOp(ctx, strategy, op); + } + } catch (error) { + if (!(error instanceof ConvexError)) throw error; + const rawCode = + typeof error.data?.code === "string" + ? error.data.code + : "INTERNAL_ERROR"; + const message = + typeof error.data?.message === "string" + ? error.data.message + : error.message; + result = { + status: "failed", + code: rawCode, + rawCode, + message, + }; + } } if ( diff --git a/convex/pages.ts b/convex/pages.ts index 216c9e73..21937f28 100644 --- a/convex/pages.ts +++ b/convex/pages.ts @@ -1,6 +1,10 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { assertStrategyRole } from "./lib/auth"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; import { purgeDeletedPageOrphansRef } from "./maintenance"; import { clampPageIndex, @@ -41,6 +45,7 @@ export const listForStrategy = query({ export const add = mutation({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), expectedRevision: v.number(), pagePublicId: v.string(), @@ -52,6 +57,7 @@ export const add = mutation({ }, returns: revisionResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); const existingPage = await ctx.db @@ -147,6 +153,7 @@ export const add = mutation({ export const rename = mutation({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), pagePublicId: v.string(), name: v.string(), @@ -155,6 +162,7 @@ export const rename = mutation({ }, returns: revisionResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); const page = await getPageByPublicId(ctx, args.pagePublicId); @@ -185,12 +193,14 @@ export const rename = mutation({ const deletePage = mutation({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), pagePublicId: v.string(), expectedRevision: v.number(), }, returns: revisionResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); const pages = await ctx.db @@ -220,6 +230,7 @@ const deletePage = mutation({ await ctx.db.delete(page._id); await ctx.scheduler.runAfter(0, purgeDeletedPageOrphansRef, { pageId: page._id, + strategyId: strategy._id, }); const ordered = sortByNumberField( @@ -249,12 +260,14 @@ const deletePage = mutation({ export const reorder = mutation({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), orderedPagePublicIds: v.array(v.string()), expectedRevision: v.number(), }, returns: revisionResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); const pages = await ctx.db diff --git a/convex/schema.ts b/convex/schema.ts index 3d433aca..47eb17f7 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -177,6 +177,7 @@ export default defineSchema({ etag: v.optional(v.string()), uploadedAt: v.optional(v.number()), deletedAt: v.optional(v.number()), + cleanupClaimedAt: v.optional(v.number()), createdAt: v.optional(v.number()), updatedAt: v.optional(v.number()), // Legacy rows may still have a storagePath that can help infer the extension. @@ -197,6 +198,7 @@ export default defineSchema({ "uploadStatus", ]) .index("by_uploadStatus_and_updatedAt", ["uploadStatus", "updatedAt"]) + .index("by_storageId", ["storageId"]) .index("by_objectKey", ["objectKey"]), operationEvents: defineTable({ strategyId: v.id("strategies"), diff --git a/convex/shares.ts b/convex/shares.ts index 77ecde60..85945c65 100644 --- a/convex/shares.ts +++ b/convex/shares.ts @@ -9,6 +9,10 @@ import { type CollaboratorRole, } from "./lib/auth"; import { getFolderByPublicId, getStrategyByPublicId } from "./lib/entities"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; import { notFoundError, errorWithCode, @@ -83,6 +87,7 @@ export const list = query({ export const create = mutation({ args: { + ...cloudProtocolArgs, targetType: targetTypeValidator, targetPublicId: v.string(), token: v.string(), @@ -90,6 +95,7 @@ export const create = mutation({ }, returns: okResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const user = await requireCurrentUser(ctx); const resolved = await resolveTarget(ctx, args.targetType, args.targetPublicId); @@ -124,12 +130,14 @@ export const create = mutation({ export const revoke = mutation({ args: { + ...cloudProtocolArgs, targetType: targetTypeValidator, targetPublicId: v.string(), token: v.string(), }, returns: okResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const resolved = await resolveTarget(ctx, args.targetType, args.targetPublicId); if (resolved.strategy !== null) { @@ -165,6 +173,7 @@ export const revoke = mutation({ export const redeem = mutation({ args: { + ...cloudProtocolArgs, token: v.string(), }, returns: v.union( @@ -183,6 +192,7 @@ export const redeem = mutation({ }), ), handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const user = await requireCurrentUser(ctx); const link = await ctx.db .query("shareLinks") diff --git a/convex/strategies.ts b/convex/strategies.ts index a0031190..f1299e6d 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -12,6 +12,10 @@ import { } from "./lib/auth"; import type { StrategyRole } from "./lib/auth"; import { getFolderByPublicId, getStrategyByPublicId } from "./lib/entities"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; import { mapThemePaletteValidator, strategySettingsValidator, @@ -21,6 +25,7 @@ import { forbiddenError, } from "./lib/errors"; import { purgeDeletedPageOrphansRef } from "./maintenance"; +import { markDeletedStrategyImageAssetsRef } from "./images"; import { createResultValidator, okResultValidator, @@ -454,6 +459,7 @@ export const getHeader = query({ export const create = mutation({ args: { + ...cloudProtocolArgs, publicId: v.string(), name: v.string(), mapData: v.string(), @@ -463,6 +469,7 @@ export const create = mutation({ }, returns: createResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const user = await requireCurrentUser(ctx); return await createStrategyWithInitialPageRecord(ctx, args, user._id, { publicId: createPublicId(), @@ -475,6 +482,7 @@ export const create = mutation({ export const createWithInitialPage = mutation({ args: { + ...cloudProtocolArgs, publicId: v.string(), name: v.string(), mapData: v.string(), @@ -489,6 +497,7 @@ export const createWithInitialPage = mutation({ }, returns: createResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const user = await requireCurrentUser(ctx); return await createStrategyWithInitialPageRecord(ctx, args, user._id, { publicId: args.initialPagePublicId, @@ -502,6 +511,7 @@ export const createWithInitialPage = mutation({ export const update = mutation({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), expectedRevision: v.number(), name: v.optional(v.string()), @@ -513,6 +523,7 @@ export const update = mutation({ }, returns: revisionResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); @@ -563,14 +574,16 @@ export const update = mutation({ export const move = mutation({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), expectedRevision: v.number(), folderPublicId: v.optional(v.string()), }, returns: revisionResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); - await assertStrategyRole(ctx, strategy, "editor"); + await assertStrategyRole(ctx, strategy, "owner"); if (args.expectedRevision !== strategy.revision) { throw conflictError("Strategy revision mismatch"); @@ -598,11 +611,13 @@ export const move = mutation({ const deleteStrategy = mutation({ args: { + ...cloudProtocolArgs, strategyPublicId: v.string(), expectedRevision: v.number(), }, returns: okResultValidator, handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "owner"); if (args.expectedRevision !== strategy.revision) { @@ -625,6 +640,7 @@ const deleteStrategy = mutation({ await ctx.db.delete(page._id); await ctx.scheduler.runAfter(0, purgeDeletedPageOrphansRef, { pageId: page._id, + strategyId: strategy._id, }); } @@ -652,6 +668,9 @@ const deleteStrategy = mutation({ await ctx.db.delete(shareLink._id); } + await ctx.scheduler.runAfter(0, markDeletedStrategyImageAssetsRef, { + strategyId: strategy._id, + }); await ctx.db.delete(strategy._id); return { ok: true } as const; }, diff --git a/convex/syncBoundaries.test.ts b/convex/syncBoundaries.test.ts index 6482d1eb..628f94bd 100644 --- a/convex/syncBoundaries.test.ts +++ b/convex/syncBoundaries.test.ts @@ -6,6 +6,13 @@ import { import { makeFunctionReference } from "convex/server"; import { beforeAll, describe, expect, test, vi } from "vitest"; import type { DataModel } from "./_generated/dataModel"; +import { + CLOUD_OPERATION_TOO_LARGE_MESSAGE, + CURRENT_CLOUD_PROTOCOL_VERSION, + MAX_CLOUD_ARRAY_ENTRIES, + MAX_CLOUD_OPERATION_BYTES, + serializedConvexValueUtf8Bytes, +} from "./lib/cloudProtocol"; import schema from "./schema"; import { modules } from "./test.setup"; @@ -81,12 +88,15 @@ async function createHarness(): Promise<{ }> { const t = convexTest(schema, modules); const owner = t.withIdentity(identity); - await owner.mutation(ensureCurrentUser, {}); + await owner.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }); return { t, owner }; } async function createBaseStrategy(owner: Harness) { await owner.mutation(createStrategyWithInitialPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, publicId: strategyPublicId, name: "Sync boundary strategy", mapData: "ascent", @@ -105,7 +115,7 @@ async function applyOps( return (await owner.mutation(applyBatch, { strategyPublicId, clientId, - clientProtocolVersion: 3, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, ops: ops.map(toProtocol3Op), })) as { strategyPublicId: string; @@ -436,6 +446,7 @@ describe("page-scoped read contract", () => { try { const { t, owner } = await createHarness(); await owner.mutation(createStrategyWithInitialPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, publicId: strategyPublicId, name: "Page names", mapData: "ascent", @@ -1195,6 +1206,7 @@ describe("record-scoped write contract", () => { const { t, owner } = await createHarness(); await createBaseStrategy(owner); const added = (await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, pagePublicId: pageB, @@ -1206,6 +1218,7 @@ describe("record-scoped write contract", () => { expect(added.revision).toBe(1); const deleted = (await owner.mutation(deletePage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, pagePublicId: pageB, expectedRevision: 1, @@ -1213,6 +1226,7 @@ describe("record-scoped write contract", () => { expect(deleted).toMatchObject({ revision: 2 }); const replayed = (await owner.mutation(deletePage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, pagePublicId: pageB, expectedRevision: 1, @@ -1228,6 +1242,7 @@ describe("record-scoped write contract", () => { const { owner } = await createHarness(); await createBaseStrategy(owner); await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, pagePublicId: pageB, @@ -1238,6 +1253,7 @@ describe("record-scoped write contract", () => { }); const replayed = (await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, pagePublicId: pageB, @@ -1258,6 +1274,7 @@ describe("record-scoped write contract", () => { await createBaseStrategy(owner); await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, pagePublicId: pageB, @@ -1267,6 +1284,7 @@ describe("record-scoped write contract", () => { settings: settingsB, }); await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 1, pagePublicId: "page-c", @@ -1287,6 +1305,7 @@ describe("record-scoped write contract", () => { ]); const replayed = (await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 1, pagePublicId: "page-c", @@ -1301,6 +1320,7 @@ describe("record-scoped write contract", () => { const { owner } = await createHarness(); await createBaseStrategy(owner); await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, expectedRevision: 0, pagePublicId: pageB, @@ -1312,6 +1332,7 @@ describe("record-scoped write contract", () => { await expect( owner.mutation(reorderPages, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, strategyPublicId, orderedPagePublicIds: [pageA, pageA], expectedRevision: 1, @@ -1524,6 +1545,114 @@ describe("cloud protocol v3 boundary", () => { }); }); + test("a policy-oversized Unicode op fails without blocking its sibling", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + const oversized = { + opId: "oversized-drawing", + type: "element.add" as const, + elementPublicId: "large-element", + pagePublicId: pageA, + payload: { + kind: "drawing" as const, + payloadVersion: 1, + data: { + elementType: "drawing", + encodedPoints: "界".repeat(310_000), + }, + }, + sortIndex: 0, + }; + const serializedBytes = serializedConvexValueUtf8Bytes(oversized); + expect(serializedBytes).toBeGreaterThan(MAX_CLOUD_OPERATION_BYTES); + expect(serializedBytes).toBeLessThan(1024 * 1024); + + const response = (await owner.mutation(applyBatch, { + strategyPublicId, + clientId: "oversized-op-client", + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + ops: [ + oversized, + { + opId: "independent-strategy-patch", + type: "strategy.patch", + payload: { name: "Independent change landed" }, + expectedStrategyRevision: 0, + }, + ], + })) as { results: Array> }; + + expect(response.results).toEqual([ + { + opId: "oversized-drawing", + status: "failed", + code: "INVALID_PAYLOAD", + rawCode: "INVALID_PAYLOAD", + message: CLOUD_OPERATION_TOO_LARGE_MESSAGE, + }, + { + opId: "independent-strategy-patch", + status: "applied", + appliedRevision: 1, + }, + ]); + await expect( + owner.query(getShell, { strategyPublicId }), + ).resolves.toMatchObject({ + header: { name: "Independent change landed", revision: 1 }, + }); + }); + + test("a policy-wide array fails per op without blocking its sibling", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + const response = (await owner.mutation(applyBatch, { + strategyPublicId, + clientId: "wide-array-client", + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + ops: [ + { + opId: "wide-drawing", + type: "element.add", + elementPublicId: "wide-element", + pagePublicId: pageA, + payload: { + kind: "drawing", + payloadVersion: 1, + data: { + points: Array.from( + { length: MAX_CLOUD_ARRAY_ENTRIES + 1 }, + () => 0, + ), + }, + }, + sortIndex: 0, + }, + { + opId: "independent-wide-array-sibling", + type: "strategy.patch", + payload: { name: "Wide sibling landed" }, + expectedStrategyRevision: 0, + }, + ], + })) as { results: Array> }; + + expect(response.results).toEqual([ + { + opId: "wide-drawing", + status: "failed", + code: "INVALID_PAYLOAD", + rawCode: "INVALID_PAYLOAD", + message: CLOUD_OPERATION_TOO_LARGE_MESSAGE, + }, + { + opId: "independent-wide-array-sibling", + status: "applied", + appliedRevision: 1, + }, + ]); + }); + test("the wire validator rejects an illegal operation discriminator", async () => { const { owner } = await createHarness(); @@ -1531,7 +1660,7 @@ describe("cloud protocol v3 boundary", () => { owner.mutation(applyBatch, { strategyPublicId, clientId: "illegal-op", - clientProtocolVersion: 3, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, ops: [ { opId: "illegal-page-delete", diff --git a/convex/users.ts b/convex/users.ts index 0daeda28..19000287 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -4,13 +4,18 @@ import { getCanonicalExternalId, } from "./lib/auth"; import { unauthenticatedError } from "./lib/errors"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; import { okResultValidator } from "./lib/publicValidators"; import { v } from "convex/values"; export const ensureCurrentUser = mutation({ - args: {}, + args: cloudProtocolArgs, returns: okResultValidator, - handler: async (ctx) => { + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); const identity = await ctx.auth.getUserIdentity(); if (identity === null) { throw unauthenticatedError(); diff --git a/docs/auth_flow_reference.md b/docs/auth_flow_reference.md index 486797a5..64fa988e 100644 --- a/docs/auth_flow_reference.md +++ b/docs/auth_flow_reference.md @@ -24,6 +24,7 @@ That means the flow is: These are the files that define the current behavior: - `lib/main.dart` +- `lib/config/cloud_build_config.dart` - `lib/providers/auth_provider.dart` - `lib/collab/convex_strategy_repository.dart` - `lib/collab/generated/` @@ -41,11 +42,14 @@ These are the files that define the current behavior: At startup the app initializes the Convex client and then Supabase: ```dart +final cloudBuildConfig = CloudBuildConfig.fromEnvironment( + isReleaseMode: kReleaseMode, +); await ConvexClient.initialize( - const ConvexConfig( - deploymentUrl: 'https://majestic-eel-413.convex.cloud', - clientId: 'dev:majestic-eel-413', - operationTimeout: Duration(seconds: 30), + ConvexConfig( + deploymentUrl: cloudBuildConfig.deploymentUrl, + clientId: cloudBuildConfig.clientId, + operationTimeout: const Duration(seconds: 30), healthCheckQuery: defaultConvexHealthCheckQuery, ), ); @@ -60,6 +64,9 @@ await Supabase.initialize( Why this matters: - `ConvexClient.initialize(...)` creates the global Convex client used by the app. +- `CloudBuildConfig` selects the named development deployment for local, CI, + and prerelease builds. Stable and Store release scripts require an explicit + production URL and client ID. - `Supabase.initialize(...)` sets up the auth provider that will issue JWTs. - `detectSessionInUri: false` is intentional because the desktop app handles OAuth callback URIs itself instead of relying on automatic URI parsing. diff --git a/docs/cloud_online_release_gaps.md b/docs/cloud_online_release_gaps.md index 988535ae..7dcc4c34 100644 --- a/docs/cloud_online_release_gaps.md +++ b/docs/cloud_online_release_gaps.md @@ -210,8 +210,8 @@ npm run snapshot:convex-contract:check npm run audit:convex-contract fvm flutter analyze --no-fatal-infos fvm flutter test -fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons -fvm flutter build macos --no-tree-shake-icons +fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development +fvm flutter build macos --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development ``` On the pull request, CI also builds the Windows installer, runs diff --git a/docs/cloudflare_r2_media_storage.md b/docs/cloudflare_r2_media_storage.md index 6c8f0a53..cc720b63 100644 --- a/docs/cloudflare_r2_media_storage.md +++ b/docs/cloudflare_r2_media_storage.md @@ -21,7 +21,7 @@ New uploads fail with an actionable Convex error if the required R2 env vars are 2. Convex checks editor access, inserts a pending `imageAssets` row, creates a high-entropy immutable R2 object key, and returns a short-lived signed PUT URL. 3. The client uploads bytes directly to R2 with the signed `Content-Type` header. 4. The client calls `images:completeUpload` with the upload intent metadata. -5. Convex verifies the R2 object exists, checks size and MIME metadata, marks the row active, and deletes replaced objects after the new row is active. +5. Convex verifies the R2 object exists, checks size and MIME metadata, and marks the row active. Replaced objects become durable deletion tombstones and are removed by the cleanup worker. Strategy/page/lineup payloads store image IDs and local metadata only. Public render URLs are returned from `images:listForStrategy` and `images:getAssetUrl`; they are not persisted in strategy payloads. @@ -30,9 +30,11 @@ Strategy/page/lineup payloads store image IDs and local metadata only. Public re - Expired upload URL: the client does not persist the signed URL. A retry requests a fresh pending upload intent. - MIME mismatch: `Content-Type` is signed for PUT and completion verifies R2 metadata against the file extension. - Oversized image: completion rejects and deletes the uploaded R2 object if it exceeds `R2_MAX_IMAGE_BYTES`. -- PUT succeeds but completion fails: the pending row and object key remain available for retry. `images:sweepStaleUploadsForStrategy` can delete old pending/failed objects later. -- Pending upload never completed: pending/failed rows are indexed by `uploadStatus` and `updatedAt` for sweep. +- PUT succeeds but completion fails: the pending row and object key remain available for retry. The hourly `mark-stale-image-uploads-deleted` job reclaims old pending and failed uploads without user authentication. +- Pending upload never completed: pending/failed rows are indexed by `uploadStatus` and `updatedAt`. The cron job marks them for cleanup after 24 hours. - Replacing an asset: the new immutable R2 object is activated before older active rows for the same strategy asset ID are marked deleted. +- Page and Strategy deletion: page cleanup only marks strategy-owned assets that no remaining Page or Lineup references. Strategy cleanup marks every asset with that Strategy's exact Convex ID and leaves legacy rows without a `strategyId` alone. +- R2 deletion fails: the deleted `imageAssets` row keeps its object key. The worker retries after one minute, and the hourly sweep provides a second recovery path. A 404 counts as success, so retries are safe. - Duplicate upload attempts: each upload intent gets a unique object key; completion is tied to its `uploadId`. - Legacy dev data: rows with `storageId` and no R2 provider are treated as active Convex-storage assets. - Strategy access revoked: Convex stops returning URLs to unauthorized viewers, but already-copied public custom-domain URLs can remain reachable until the object is deleted or Cloudflare access controls/cache expire. diff --git a/docs/release_process.md b/docs/release_process.md index f4df0851..1d9d86dd 100644 --- a/docs/release_process.md +++ b/docs/release_process.md @@ -19,21 +19,91 @@ Keep them separate. Run the workflow for the channel you actually want to publis ## Before Any Release -1. Make sure the branch contains the changes you want to ship. +1. Check the branch. Stable desktop and every Store build must run from + `main`. The release scripts stop before a version bump or build on any other + branch. Desktop prerelease builds may run from a feature branch. 2. Run the focused validation locally: - `fvm flutter test test/update_checker_test.dart` + - `fvm flutter test test/cloud_build_config_test.dart` + - `powershell -ExecutionPolicy Bypass -File scripts/test_release_safety.ps1` - `fvm flutter analyze` 3. Check `pubspec.yaml` and confirm the version you want to release. 4. Create or update the matching release metadata file in `release/metadata/`. 5. Write player-facing release notes in that metadata file. +## Cloud build configuration + +Icarus has one named development Convex configuration in source. Local +development, CI, and desktop prerelease builds select it with +`ICARUS_CLOUD_ENVIRONMENT=development`. + +An ordinary debug run defaults to development. A release-mode app with no +`ICARUS_CLOUD_ENVIRONMENT` stops during startup, so any new release entry point +must choose `development` or `production` deliberately. + +Stable desktop and Store builds select `production` and require both of these +GitHub repository variables: + +- `ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL` +- `ICARUS_PRODUCTION_CONVEX_CLIENT_ID` + +The production URL and client ID are public build inputs, not deploy keys. The +release scripts pass them to Flutter through a temporary Dart-defines file and +delete that file after the build. A missing value, invalid URL, or the known +development deployment stops the release before Flutter runs. + +Use the deployment's canonical `https://.convex.cloud` client URL. +The release validator does not accept custom domains, and `.convex.site` is the +HTTP Actions URL rather than the client deployment URL. See Convex's +[deployment URL guide](https://docs.convex.dev/client/react/deployment-urls) +and [system environment URL definitions](https://docs.convex.dev/production/environment-variables). + +Stable desktop, Store, and production backend workflows all enter the protected +GitHub `Production` environment before they can build or publish. Desktop +prerelease skips that environment and remains available on feature branches. + +For a local stable build, set the same two environment variables in the shell +before running `scripts/release_desktop.ps1`. Never put a Convex deploy key in a +Dart define or repository variable. + +## One-time production Convex setup + +No production deployment or key is checked into this repository. Before the +first production release: + +1. Create or select the Icarus production deployment in Convex. Record its + `.convex.cloud` client URL in the repository variable above. +2. Create a deployment-scoped production deploy key with only the permissions + needed to deploy. Convex supports this in the deployment settings or with + `npx convex deployment token create github-production --deployment prod`. + See the [Convex deploy-key documentation](https://docs.convex.dev/cli/deploy-key-types). +3. Create a GitHub environment named `Production`. Restrict its deployment + branches to `main`, add any required reviewers, and add the secret + `CONVEX_PRODUCTION_DEPLOY_KEY`. +4. Add the public production URL and a stable client identifier, such as the + identifier chosen for the shipped Icarus client, to the two GitHub repository + variables in the previous section. +5. Configure the production deployment's required R2 environment values before + testing cloud media. The backend reports the exact missing names if they are + absent. + +Run the manual `Deploy Convex Production` workflow from `main` and type +`deploy-production`. The workflow enters the GitHub `Production` environment, +requires a `prod:` deploy key, installs locked dependencies, runs TypeScript and +Convex tests, then runs `npx convex deploy --typecheck enable`. Convex documents +that `CONVEX_DEPLOY_KEY` selects the deployment associated with that key. See +the [`convex deploy` reference](https://docs.convex.dev/cli/reference/deploy). + +The production workflow never reads `CONVEX_PREVIEW_DEPLOY_KEY`. That secret is +only for the isolated contract deployment in CI. + ## Desktop Release Checklist Use this when you want to publish the direct installer channel. 1. Go to `Actions` in GitHub. 2. Open `Release Desktop`. -3. Click `Run workflow`. +3. Confirm the selected branch is `main`, then click `Run workflow`. 4. Choose: - `version_bump`: `none` if the version is already correct, otherwise `patch`, `minor`, or `major` - `channel`: `stable` @@ -81,7 +151,7 @@ Use this when you want to publish the Microsoft Store channel. 1. Go to `Actions` in GitHub. 2. Open `Release Store`. -3. Click `Run workflow`. +3. Confirm the selected branch is `main`, then click `Run workflow`. 4. Choose: - `version_bump`: `none` if the version is already correct, otherwise `patch`, `minor`, or `major` - `publish_to_store`: `false` for a dry run, `true` when you are ready to submit @@ -110,6 +180,9 @@ Use this when you want to publish the Microsoft Store channel. - `scripts/publish_prerelease_local.ps1` pushes the staged site content to `gh-pages`. - GitHub Pages should be configured to serve `gh-pages` from `/ (root)`. - No extra Pages deploy workflow is needed for prerelease testing. +- `release/metadata/4.6.1+97.json` is prerelease-only while the online beta + checks remain open. Do not add `stable` to its channels to make a stable + manifest build pass. - Direct desktop installs now use a per-user install path and per-user registry registration. - Store installs should continue to use the Microsoft Store update path only. - The metadata file should not be a generic `template.json` in the live metadata folder, because the manifest generator treats every JSON file there as a real release entry. diff --git a/lib/collab/cloud_media_models.dart b/lib/collab/cloud_media_models.dart index c5b4e6d1..fe128f2a 100644 --- a/lib/collab/cloud_media_models.dart +++ b/lib/collab/cloud_media_models.dart @@ -33,6 +33,7 @@ String mimeTypeForImageExtension(String extension) { class CloudMediaUploadJob { CloudMediaUploadJob({ required this.jobId, + required this.accountId, required this.strategyPublicId, required this.assetPublicId, required this.fileExtension, @@ -40,6 +41,7 @@ class CloudMediaUploadJob { required this.state, required this.attempts, required this.updatedAt, + this.referenceDurable = true, this.width, this.height, this.byteSize, @@ -53,6 +55,7 @@ class CloudMediaUploadJob { }); final String jobId; + final String accountId; final String strategyPublicId; final String assetPublicId; final String fileExtension; @@ -67,6 +70,7 @@ class CloudMediaUploadJob { final String? etag; final DateTime? uploadUrlExpiresAt; final CloudMediaJobState state; + final bool referenceDurable; final int attempts; final String? lastError; final DateTime updatedAt; @@ -82,6 +86,7 @@ class CloudMediaUploadJob { CloudMediaUploadJob copyWith({ String? jobId, + String? accountId, String? strategyPublicId, String? assetPublicId, String? fileExtension, @@ -96,12 +101,14 @@ class CloudMediaUploadJob { Object? etag = _noChange, Object? uploadUrlExpiresAt = _noChange, CloudMediaJobState? state, + bool? referenceDurable, int? attempts, Object? lastError = _noChange, DateTime? updatedAt, }) { return CloudMediaUploadJob( jobId: jobId ?? this.jobId, + accountId: accountId ?? this.accountId, strategyPublicId: strategyPublicId ?? this.strategyPublicId, assetPublicId: assetPublicId ?? this.assetPublicId, fileExtension: fileExtension ?? this.fileExtension, @@ -125,6 +132,7 @@ class CloudMediaUploadJob { ? this.uploadUrlExpiresAt : uploadUrlExpiresAt as DateTime?, state: state ?? this.state, + referenceDurable: referenceDurable ?? this.referenceDurable, attempts: attempts ?? this.attempts, lastError: identical(lastError, _noChange) ? this.lastError diff --git a/lib/collab/cloud_sync_error_message.dart b/lib/collab/cloud_sync_error_message.dart index 95a5224e..184d7507 100644 --- a/lib/collab/cloud_sync_error_message.dart +++ b/lib/collab/cloud_sync_error_message.dart @@ -5,6 +5,10 @@ String friendlyCloudSyncError(String raw) { 'device; keep this strategy open and recover the outbox before ' 'continuing.'; } + if (lower.contains('could not be verified in the durable outbox')) { + return 'Icarus could not verify that this change was saved on this ' + 'device. Nothing was sent. Keep this strategy open and retry.'; + } if (lower.contains('forbidden')) { return 'This account does not have permission to save these changes. ' 'They remain on this device. Ask the owner for edit access, then ' @@ -14,6 +18,10 @@ String friendlyCloudSyncError(String raw) { return 'A saved cloud change is paused after repeated failures. Retry ' 'when the connection and account are healthy.'; } + if (lower.contains('too large for cloud sync')) { + return 'A saved change is too large for cloud sync. It remains saved on ' + 'this device. Reduce it, then choose Keep mine to retry.'; + } if (lower.contains('needs attention')) { return 'Another edit reached the cloud first. Your version remains ' 'saved on this device.'; diff --git a/lib/collab/collab_models.dart b/lib/collab/collab_models.dart index 8212d186..1a87ea2a 100644 --- a/lib/collab/collab_models.dart +++ b/lib/collab/collab_models.dart @@ -2,9 +2,18 @@ import 'dart:convert'; const currentCloudProtocolVersion = 3; const currentCloudPayloadVersion = 1; +const maxCloudOperationBytes = 900 * 1024; +const maxCloudBatchBytes = 15 * 1024 * 1024; +const maxCloudArrayEntries = 8000; +const cloudOperationTooLargeMessage = + 'This saved change is too large for cloud sync. It remains saved on this ' + 'device.'; typedef CloudPayload = Map; +int serializedConvexValueUtf8Bytes(Object? value) => + utf8.encode(jsonEncode(value)).length; + CloudPayload cloudElementPayload({ required String kind, required Map data, @@ -930,6 +939,39 @@ int _requiredInt(Object? value) { throw const FormatException('Op revision or index must be a number'); } +int serializedCloudOperationUtf8Bytes(StrategyOp op) => + serializedConvexValueUtf8Bytes(op.toConvexJson()); + +bool cloudOperationExceedsPolicy(StrategyOp op) { + final value = op.toConvexJson(); + return serializedConvexValueUtf8Bytes(value) > maxCloudOperationBytes || + _cloudValueExceedsArrayPolicy(value); +} + +bool _cloudValueExceedsArrayPolicy(Object? value) { + if (value is List) { + return value.length > maxCloudArrayEntries || + value.any(_cloudValueExceedsArrayPolicy); + } + if (value is Map) { + return value.values.any(_cloudValueExceedsArrayPolicy); + } + return false; +} + +int serializedCloudBatchUtf8Bytes({ + required String strategyPublicId, + required String clientId, + required Iterable ops, +}) { + return serializedConvexValueUtf8Bytes({ + 'strategyPublicId': strategyPublicId, + 'clientId': clientId, + 'clientProtocolVersion': currentCloudProtocolVersion, + 'ops': ops.map((op) => op.toConvexJson()).toList(growable: false), + }); +} + class PendingOp { const PendingOp({ required this.op, diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index 833d0533..baf62564 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -27,7 +27,9 @@ class ConvexStrategyRepository { final IcarusConvexApi _api; Future ensureCurrentUser() async { - await _api.users.ensureCurrentUser(); + await _api.users.ensureCurrentUser( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + ); } Stream> watchAllFolders() { @@ -124,6 +126,7 @@ class ConvexStrategyRepository { int? height, }) async { final result = await _api.images.generateUploadUrl( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), strategyPublicId: strategyPublicId, assetPublicId: assetPublicId, mimeType: mimeType, @@ -158,6 +161,7 @@ class ConvexStrategyRepository { int? height, }) async { await _api.images.completeUpload( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), strategyPublicId: strategyPublicId, assetPublicId: assetPublicId, provider: provider == null @@ -194,6 +198,19 @@ class ConvexStrategyRepository { required List ops, }) async { if (ops.isEmpty) return const []; + for (final op in ops) { + if (cloudOperationExceedsPolicy(op)) { + throw StateError(cloudOperationTooLargeMessage); + } + } + if (serializedCloudBatchUtf8Bytes( + strategyPublicId: strategyPublicId, + clientId: clientId, + ops: ops, + ) > + maxCloudBatchBytes) { + throw StateError('Cloud operation batch exceeds the payload policy.'); + } final typedOps = ops.indexed .map( @@ -224,6 +241,7 @@ class ConvexStrategyRepository { int? customColorValue, }) async { await _api.folders.create( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), publicId: publicId, name: name, parentFolderPublicId: _optional(parentFolderPublicId), @@ -250,6 +268,7 @@ class ConvexStrategyRepository { bool clearCustomColorValue = false, }) async { await _api.folders.update( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), folderPublicId: folderPublicId, name: _optional(name), iconId: _optionalNumber(iconId), @@ -265,7 +284,10 @@ class ConvexStrategyRepository { } Future deleteFolder(String folderPublicId) async { - await _api.folders.delete(folderPublicId: folderPublicId); + await _api.folders.delete( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + folderPublicId: folderPublicId, + ); } Future moveFolder({ @@ -273,6 +295,7 @@ class ConvexStrategyRepository { String? parentFolderPublicId, }) async { await _api.folders.move( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), folderPublicId: folderPublicId, parentFolderPublicId: _optional(parentFolderPublicId), ); @@ -287,6 +310,7 @@ class ConvexStrategyRepository { Map? themeOverridePalette, }) async { await _api.strategies.create( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), publicId: publicId, name: name, mapData: mapData, @@ -310,6 +334,7 @@ class ConvexStrategyRepository { Map? initialPageSettings, }) async { await _api.strategies.createWithInitialPage( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), publicId: publicId, name: name, mapData: mapData, @@ -330,6 +355,7 @@ class ConvexStrategyRepository { required int expectedRevision, }) async { await _api.strategies.update( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), strategyPublicId: strategyPublicId, name: ConvexOptional.present(name), expectedRevision: expectedRevision.toDouble(), @@ -341,6 +367,7 @@ class ConvexStrategyRepository { required int expectedRevision, }) async { await _api.strategies.delete( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), strategyPublicId: strategyPublicId, expectedRevision: expectedRevision.toDouble(), ); @@ -352,6 +379,7 @@ class ConvexStrategyRepository { required int expectedRevision, }) async { await _api.strategies.move( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), strategyPublicId: strategyPublicId, folderPublicId: _optional(folderPublicId), expectedRevision: expectedRevision.toDouble(), @@ -369,6 +397,7 @@ class ConvexStrategyRepository { Map? settings, }) async { await _api.pages.add( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), strategyPublicId: strategyPublicId, pagePublicId: pagePublicId, name: name, @@ -410,6 +439,7 @@ class ConvexStrategyRepository { required String role, }) async { await _api.shares.create( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), targetType: _shareTargetType(targetType), targetPublicId: targetPublicId, token: token, @@ -423,6 +453,7 @@ class ConvexStrategyRepository { required String token, }) async { await _api.shares.revoke( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), targetType: _shareTargetType(targetType), targetPublicId: targetPublicId, token: token, @@ -430,7 +461,10 @@ class ConvexStrategyRepository { } Future redeemShareLink(String token) async { - final result = await _api.shares.redeem(token: token); + final result = await _api.shares.redeem( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + token: token, + ); return switch (result) { SharesRedeemResultFolder(:final folderPublicId, :final role) => ShareRedemption( diff --git a/lib/collab/durable_cloud_media_outbox.dart b/lib/collab/durable_cloud_media_outbox.dart new file mode 100644 index 00000000..8c373335 --- /dev/null +++ b/lib/collab/durable_cloud_media_outbox.dart @@ -0,0 +1,284 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/const/hive_boxes.dart'; + +const durableCloudMediaOutboxRecordVersion = 2; +const durableCloudMediaOutboxVersionKey = '__media_outbox_record_version__'; + +Future prepareDurableCloudMediaOutbox() async { + final box = Hive.box(HiveBoxNames.cloudMediaOutboxBox); + if (box.get(durableCloudMediaOutboxVersionKey) == + durableCloudMediaOutboxRecordVersion) { + return; + } + if (box.isNotEmpty) { + throw StateError( + 'The media outbox has an unsupported record version. ' + 'Refusing to discard pending media work.', + ); + } + await box.put( + durableCloudMediaOutboxVersionKey, + durableCloudMediaOutboxRecordVersion, + ); +} + +class DurableCloudMediaOutboxLoadIssue { + const DurableCloudMediaOutboxLoadIssue({ + required this.storageKey, + required this.error, + }); + + final String storageKey; + final String error; +} + +class DurableCloudMediaOutboxLoadResult { + const DurableCloudMediaOutboxLoadResult({ + required this.jobs, + required this.issues, + }); + + final List jobs; + final List issues; +} + +abstract class DurableCloudMediaOutboxStore { + DurableCloudMediaOutboxLoadResult load(); + Future put(CloudMediaUploadJob job); + Future putAll(Iterable jobs); + Future remove(CloudMediaUploadJob job); +} + +String durableCloudMediaOutboxStorageKey(CloudMediaUploadJob job) { + return durableCloudMediaOutboxStorageKeyFor( + accountId: job.accountId, + jobId: job.jobId, + ); +} + +String durableCloudMediaOutboxStorageKeyFor({ + required String accountId, + required String jobId, +}) { + return '${Uri.encodeComponent(accountId)}|${Uri.encodeComponent(jobId)}'; +} + +class HiveDurableCloudMediaOutboxStore implements DurableCloudMediaOutboxStore { + Box get _box => Hive.box(HiveBoxNames.cloudMediaOutboxBox); + + @override + DurableCloudMediaOutboxLoadResult load() { + final jobs = []; + final issues = []; + for (final key in _box.keys) { + final storageKey = key.toString(); + if (storageKey == durableCloudMediaOutboxVersionKey) { + continue; + } + try { + final raw = _box.get(key); + final decoded = raw is String ? jsonDecode(raw) : raw; + final json = decoded is Map + ? decoded + : Map.from(decoded as Map); + final job = _jobFromJson(json); + if (durableCloudMediaOutboxStorageKey(job) != storageKey) { + throw const FormatException( + 'Media outbox storage key does not match job', + ); + } + jobs.add(job); + } catch (error) { + issues.add( + DurableCloudMediaOutboxLoadIssue( + storageKey: storageKey, + error: error.toString(), + ), + ); + } + } + return DurableCloudMediaOutboxLoadResult(jobs: jobs, issues: issues); + } + + @override + Future put(CloudMediaUploadJob job) { + final jsonSafe = Map.from( + jsonDecode(jsonEncode(_jobToJson(job))) as Map, + ); + return _box.put(durableCloudMediaOutboxStorageKey(job), jsonSafe); + } + + @override + Future putAll(Iterable jobs) { + final values = >{ + for (final job in jobs) + durableCloudMediaOutboxStorageKey(job): Map.from( + jsonDecode(jsonEncode(_jobToJson(job))) as Map, + ), + }; + return _box.putAll(values); + } + + @override + Future remove(CloudMediaUploadJob job) => + _box.delete(durableCloudMediaOutboxStorageKey(job)); +} + +class MemoryDurableCloudMediaOutboxStore + implements DurableCloudMediaOutboxStore { + MemoryDurableCloudMediaOutboxStore([Map? initialValues]) + : values = Map.from(initialValues ?? const {}); + + final Map values; + + @override + DurableCloudMediaOutboxLoadResult load() { + final jobs = []; + final issues = []; + for (final entry in values.entries) { + try { + final value = entry.value; + final json = value is Map + ? value + : Map.from(value as Map); + final job = _jobFromJson(json); + if (durableCloudMediaOutboxStorageKey(job) != entry.key) { + throw const FormatException( + 'Media outbox storage key does not match job', + ); + } + jobs.add(job); + } catch (error) { + issues.add( + DurableCloudMediaOutboxLoadIssue( + storageKey: entry.key, + error: error.toString(), + ), + ); + } + } + return DurableCloudMediaOutboxLoadResult(jobs: jobs, issues: issues); + } + + @override + Future put(CloudMediaUploadJob job) async { + values[durableCloudMediaOutboxStorageKey(job)] = Map.from( + jsonDecode(jsonEncode(_jobToJson(job))) as Map, + ); + } + + @override + Future putAll(Iterable jobs) async { + final encoded = >{ + for (final job in jobs) + durableCloudMediaOutboxStorageKey(job): Map.from( + jsonDecode(jsonEncode(_jobToJson(job))) as Map, + ), + }; + values.addAll(encoded); + } + + @override + Future remove(CloudMediaUploadJob job) async { + values.remove(durableCloudMediaOutboxStorageKey(job)); + } +} + +final durableCloudMediaOutboxStoreProvider = + Provider( + (ref) => HiveDurableCloudMediaOutboxStore(), +); + +Map _jobToJson(CloudMediaUploadJob job) { + final accountId = job.accountId; + if (accountId.isEmpty) { + throw StateError('New media outbox records require an owning account.'); + } + return { + 'outboxVersion': durableCloudMediaOutboxRecordVersion, + 'jobId': job.jobId, + 'accountId': accountId, + 'strategyPublicId': job.strategyPublicId, + 'assetPublicId': job.assetPublicId, + 'fileExtension': job.fileExtension, + 'mimeType': job.mimeType, + if (job.width != null) 'width': job.width, + if (job.height != null) 'height': job.height, + if (job.byteSize != null) 'byteSize': job.byteSize, + if (job.provider != null) 'provider': job.provider, + if (job.uploadId != null) 'uploadId': job.uploadId, + if (job.objectKey != null) 'objectKey': job.objectKey, + if (job.storageId != null) 'storageId': job.storageId, + if (job.etag != null) 'etag': job.etag, + if (job.uploadUrlExpiresAt != null) + 'uploadUrlExpiresAt': job.uploadUrlExpiresAt!.toUtc().toIso8601String(), + 'state': job.state.name, + 'referenceDurable': job.referenceDurable, + 'attempts': job.attempts, + if (job.lastError != null) 'lastError': job.lastError, + 'updatedAt': job.updatedAt.toUtc().toIso8601String(), + }; +} + +CloudMediaUploadJob _jobFromJson(Map json) { + final version = (json['outboxVersion'] as num?)?.toInt(); + if (version != durableCloudMediaOutboxRecordVersion) { + throw FormatException('Unsupported media outbox record version: $version'); + } + return CloudMediaUploadJob( + jobId: _nonEmptyString(json['jobId'], field: 'jobId'), + accountId: _nonEmptyString(json['accountId'], field: 'accountId'), + strategyPublicId: _nonEmptyString( + json['strategyPublicId'], + field: 'strategyPublicId', + ), + assetPublicId: _nonEmptyString( + json['assetPublicId'], + field: 'assetPublicId', + ), + fileExtension: json['fileExtension'] as String? ?? '', + mimeType: _nonEmptyString(json['mimeType'], field: 'mimeType'), + width: (json['width'] as num?)?.toInt(), + height: (json['height'] as num?)?.toInt(), + byteSize: (json['byteSize'] as num?)?.toInt(), + provider: json['provider'] as String?, + uploadId: json['uploadId'] as String?, + objectKey: json['objectKey'] as String?, + storageId: json['storageId'] as String?, + etag: json['etag'] as String?, + uploadUrlExpiresAt: _optionalDate(json['uploadUrlExpiresAt']), + state: CloudMediaJobState.values.byName( + _nonEmptyString(json['state'], field: 'state'), + ), + referenceDurable: json['referenceDurable'] as bool? ?? true, + attempts: (json['attempts'] as num?)?.toInt() ?? 0, + lastError: json['lastError'] as String?, + updatedAt: _requiredDate(json['updatedAt'], field: 'updatedAt'), + ); +} + +String _nonEmptyString(Object? value, {required String field}) { + if (value is String && value.isNotEmpty) { + return value; + } + throw FormatException('Media outbox $field must be a non-empty string'); +} + +DateTime _requiredDate(Object? value, {required String field}) { + final parsed = _optionalDate(value); + if (parsed != null) { + return parsed; + } + throw FormatException('Media outbox $field must be an ISO-8601 date'); +} + +DateTime? _optionalDate(Object? value) { + if (value is! String) { + return null; + } + return DateTime.tryParse(value)?.toLocal(); +} diff --git a/lib/collab/durable_strategy_outbox.dart b/lib/collab/durable_strategy_outbox.dart index f1087e8e..4d8bc9c2 100644 --- a/lib/collab/durable_strategy_outbox.dart +++ b/lib/collab/durable_strategy_outbox.dart @@ -7,12 +7,34 @@ import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; const durableOutboxRecordVersion = 2; +const _legacyDurableOutboxRecordVersion = 1; const durableOutboxVersionKey = '__outbox_record_version__'; Future prepareDurableStrategyOutbox() async { final box = Hive.box(HiveBoxNames.strategyOutboxBox); if (box.get(durableOutboxVersionKey) == durableOutboxRecordVersion) return; - await box.clear(); + + final storageKeys = box.keys + .where((key) => key.toString() != durableOutboxVersionKey) + .toList(growable: false); + for (final key in storageKeys) { + final storageKey = key.toString(); + DurableOutboxRecord record; + try { + final raw = box.get(key); + final decoded = raw is String ? jsonDecode(raw) : raw; + final json = decoded is Map + ? decoded + : Map.from(decoded as Map); + record = DurableOutboxRecord.fromJson(json); + if (record.storageKey != storageKey) continue; + } catch (_) { + // Keep unreadable records byte-for-byte. The store loader quarantines + // them as load issues instead of risking deletion of saved cloud work. + continue; + } + await box.put(key, record.toJson()); + } await box.put(durableOutboxVersionKey, durableOutboxRecordVersion); } @@ -110,7 +132,8 @@ class DurableOutboxRecord { factory DurableOutboxRecord.fromJson(Map json) { final version = (json['outboxVersion'] as num?)?.toInt(); - if (version != durableOutboxRecordVersion) { + if (version != _legacyDurableOutboxRecordVersion && + version != durableOutboxRecordVersion) { throw FormatException('Unsupported outbox record version: $version'); } final opJson = _object(json['op'], field: 'op'); diff --git a/lib/collab/generated/convex_models.dart b/lib/collab/generated/convex_models.dart index edec1e2d..eec835b1 100644 --- a/lib/collab/generated/convex_models.dart +++ b/lib/collab/generated/convex_models.dart @@ -4549,6 +4549,7 @@ List decodeElementsListForStrategyResult( .toList(growable: false); ConvexObject encodeFoldersCreateArgs({ + required double clientProtocolVersion, ConvexOptional color = const ConvexOptional.absent(), ConvexOptional customColorValue = const ConvexOptional.absent(), ConvexOptional iconCodePoint = const ConvexOptional.absent(), @@ -4559,6 +4560,10 @@ ConvexObject encodeFoldersCreateArgs({ ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), required String publicId, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'folders.js:create.args.clientProtocolVersion', + ), if (color.isPresent) 'color': ConvexString(color.value), if (customColorValue.isPresent) 'customColorValue': _encodeNumber( @@ -4588,8 +4593,16 @@ ConvexValue decodeFoldersCreateResult(ConvexValue value) => _decodeRaw( _validateFoldersCreateResult, ); -ConvexObject encodeFoldersDeleteArgs({required String folderPublicId}) => - ConvexObject({'folderPublicId': ConvexString(folderPublicId)}); +ConvexObject encodeFoldersDeleteArgs({ + required double clientProtocolVersion, + required String folderPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'folders.js:delete.args.clientProtocolVersion', + ), + 'folderPublicId': ConvexString(folderPublicId), +}); FoldersDeleteResult decodeFoldersDeleteResult(ConvexValue value) => FoldersDeleteResult.decode(value, 'folders.js:delete.returns'); @@ -4613,9 +4626,14 @@ List decodeFoldersListTreeResult( .toList(growable: false); ConvexObject encodeFoldersMoveArgs({ + required double clientProtocolVersion, required String folderPublicId, ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'folders.js:move.args.clientProtocolVersion', + ), 'folderPublicId': ConvexString(folderPublicId), if (parentFolderPublicId.isPresent) 'parentFolderPublicId': ConvexString(parentFolderPublicId.value), @@ -4628,6 +4646,7 @@ ConvexObject encodeFoldersUpdateArgs({ ConvexOptional clearCustomColorValue = const ConvexOptional.absent(), ConvexOptional clearIconFontFamily = const ConvexOptional.absent(), ConvexOptional clearIconFontPackage = const ConvexOptional.absent(), + required double clientProtocolVersion, ConvexOptional color = const ConvexOptional.absent(), ConvexOptional customColorValue = const ConvexOptional.absent(), required String folderPublicId, @@ -4643,6 +4662,10 @@ ConvexObject encodeFoldersUpdateArgs({ 'clearIconFontFamily': ConvexBoolean(clearIconFontFamily.value), if (clearIconFontPackage.isPresent) 'clearIconFontPackage': ConvexBoolean(clearIconFontPackage.value), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'folders.js:update.args.clientProtocolVersion', + ), if (color.isPresent) 'color': ConvexString(color.value), if (customColorValue.isPresent) 'customColorValue': _encodeNumber( @@ -4678,6 +4701,7 @@ HealthPingResult decodeHealthPingResult(ConvexValue value) => ConvexObject encodeImagesCompleteUploadArgs({ required String assetPublicId, ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, ConvexOptional etag = const ConvexOptional.absent(), ConvexOptional fileExtension = const ConvexOptional.absent(), ConvexOptional height = const ConvexOptional.absent(), @@ -4696,6 +4720,10 @@ ConvexObject encodeImagesCompleteUploadArgs({ byteSize.value, 'images.js:completeUpload.args.byteSize', ), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'images.js:completeUpload.args.clientProtocolVersion', + ), if (etag.isPresent) 'etag': ConvexString(etag.value), if (fileExtension.isPresent) 'fileExtension': ConvexString(fileExtension.value), @@ -4723,9 +4751,14 @@ ImagesCompleteUploadResult decodeImagesCompleteUploadResult( ConvexObject encodeImagesDeleteAssetRefArgs({ required String assetPublicId, + required double clientProtocolVersion, required String strategyPublicId, }) => ConvexObject({ 'assetPublicId': ConvexString(assetPublicId), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'images.js:deleteAssetRef.args.clientProtocolVersion', + ), 'strategyPublicId': ConvexString(strategyPublicId), }); @@ -4735,6 +4768,7 @@ FoldersDeleteResult decodeImagesDeleteAssetRefResult(ConvexValue value) => ConvexObject encodeImagesGenerateUploadUrlArgs({ required String assetPublicId, ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, required String fileExtension, ConvexOptional height = const ConvexOptional.absent(), required String mimeType, @@ -4747,6 +4781,10 @@ ConvexObject encodeImagesGenerateUploadUrlArgs({ byteSize.value, 'images.js:generateUploadUrl.args.byteSize', ), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'images.js:generateUploadUrl.args.clientProtocolVersion', + ), 'fileExtension': ConvexString(fileExtension), if (height.isPresent) 'height': _encodeNumber( @@ -4796,11 +4834,16 @@ List decodeImagesListForStrategyResult( .toList(growable: false); ConvexObject encodeInvitesCreateArgs({ + required double clientProtocolVersion, ConvexOptional expiresAt = const ConvexOptional.absent(), required InvitesCreateArgsRole role, required String strategyPublicId, required String token, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'invites.js:create.args.clientProtocolVersion', + ), if (expiresAt.isPresent) 'expiresAt': _encodeNumber( expiresAt.value, @@ -4826,16 +4869,29 @@ ConvexObject encodeInvitesGetArgs({ ConvexValue decodeInvitesGetResult(ConvexValue value) => _decodeRaw(value, 'invites.js:get.returns', _validateInvitesGetResult); -ConvexObject encodeInvitesRedeemArgs({required String token}) => - ConvexObject({'token': ConvexString(token)}); +ConvexObject encodeInvitesRedeemArgs({ + required double clientProtocolVersion, + required String token, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'invites.js:redeem.args.clientProtocolVersion', + ), + 'token': ConvexString(token), +}); InvitesRedeemResult decodeInvitesRedeemResult(ConvexValue value) => InvitesRedeemResult.decode(value, 'invites.js:redeem.returns'); ConvexObject encodeInvitesRevokeArgs({ + required double clientProtocolVersion, required String strategyPublicId, required String token, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'invites.js:revoke.args.clientProtocolVersion', + ), 'strategyPublicId': ConvexString(strategyPublicId), 'token': ConvexString(token), }); @@ -4915,6 +4971,7 @@ PageGetSnapshotResult decodePageGetSnapshotResult(ConvexValue value) => PageGetSnapshotResult.decode(value, 'page.js:getSnapshot.returns'); ConvexObject encodePagesAddArgs({ + required double clientProtocolVersion, required double expectedRevision, required bool isAttack, ConvexOptional isAutoNamed = const ConvexOptional.absent(), @@ -4925,6 +4982,10 @@ ConvexObject encodePagesAddArgs({ required double sortIndex, required String strategyPublicId, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'pages.js:add.args.clientProtocolVersion', + ), 'expectedRevision': _encodeNumber( expectedRevision, 'pages.js:add.args.expectedRevision', @@ -4943,10 +5004,15 @@ ConvexValue decodePagesAddResult(ConvexValue value) => _decodeRaw(value, 'pages.js:add.returns', _validatePagesAddResult); ConvexObject encodePagesDeleteArgs({ + required double clientProtocolVersion, required double expectedRevision, required String pagePublicId, required String strategyPublicId, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'pages.js:delete.args.clientProtocolVersion', + ), 'expectedRevision': _encodeNumber( expectedRevision, 'pages.js:delete.args.expectedRevision', @@ -4974,12 +5040,17 @@ List decodePagesListForStrategyResult( .toList(growable: false); ConvexObject encodePagesRenameArgs({ + required double clientProtocolVersion, required double expectedRevision, ConvexOptional isAutoNamed = const ConvexOptional.absent(), required String name, required String pagePublicId, required String strategyPublicId, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'pages.js:rename.args.clientProtocolVersion', + ), 'expectedRevision': _encodeNumber( expectedRevision, 'pages.js:rename.args.expectedRevision', @@ -4994,10 +5065,15 @@ ConvexValue decodePagesRenameResult(ConvexValue value) => _decodeRaw(value, 'pages.js:rename.returns', _validatePagesAddResult); ConvexObject encodePagesReorderArgs({ + required double clientProtocolVersion, required double expectedRevision, required List orderedPagePublicIds, required String strategyPublicId, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'pages.js:reorder.args.clientProtocolVersion', + ), 'expectedRevision': _encodeNumber( expectedRevision, 'pages.js:reorder.args.expectedRevision', @@ -5014,11 +5090,16 @@ ConvexValue decodePagesReorderResult(ConvexValue value) => _decodeRaw(value, 'pages.js:reorder.returns', _validatePagesAddResult); ConvexObject encodeSharesCreateArgs({ + required double clientProtocolVersion, required InvitesCreateArgsRole role, required String targetPublicId, required SharesCreateArgsTargetType targetType, required String token, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'shares.js:create.args.clientProtocolVersion', + ), 'role': ConvexString(role.wireName), 'targetPublicId': ConvexString(targetPublicId), 'targetType': ConvexString(targetType.wireName), @@ -5046,17 +5127,30 @@ List decodeSharesListResult(ConvexValue value) => ) .toList(growable: false); -ConvexObject encodeSharesRedeemArgs({required String token}) => - ConvexObject({'token': ConvexString(token)}); +ConvexObject encodeSharesRedeemArgs({ + required double clientProtocolVersion, + required String token, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'shares.js:redeem.args.clientProtocolVersion', + ), + 'token': ConvexString(token), +}); SharesRedeemResult decodeSharesRedeemResult(ConvexValue value) => SharesRedeemResult.decode(value, 'shares.js:redeem.returns'); ConvexObject encodeSharesRevokeArgs({ + required double clientProtocolVersion, required String targetPublicId, required SharesCreateArgsTargetType targetType, required String token, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'shares.js:revoke.args.clientProtocolVersion', + ), 'targetPublicId': ConvexString(targetPublicId), 'targetType': ConvexString(targetType.wireName), 'token': ConvexString(token), @@ -5066,6 +5160,7 @@ FoldersDeleteResult decodeSharesRevokeResult(ConvexValue value) => FoldersDeleteResult.decode(value, 'shares.js:revoke.returns'); ConvexObject encodeStrategiesCreateArgs({ + required double clientProtocolVersion, ConvexOptional folderPublicId = const ConvexOptional.absent(), required String mapData, required String name, @@ -5077,6 +5172,10 @@ ConvexObject encodeStrategiesCreateArgs({ const ConvexOptional.absent(), ConvexOptional themeProfileId = const ConvexOptional.absent(), }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:create.args.clientProtocolVersion', + ), if (folderPublicId.isPresent) 'folderPublicId': ConvexString(folderPublicId.value), 'mapData': ConvexString(mapData), @@ -5097,6 +5196,7 @@ ConvexValue decodeStrategiesCreateResult(ConvexValue value) => _decodeRaw( ); ConvexObject encodeStrategiesCreateWithInitialPageArgs({ + required double clientProtocolVersion, ConvexOptional folderPublicId = const ConvexOptional.absent(), required bool initialPageIsAttack, ConvexOptional initialPageIsAutoNamed = const ConvexOptional.absent(), @@ -5115,6 +5215,10 @@ ConvexObject encodeStrategiesCreateWithInitialPageArgs({ const ConvexOptional.absent(), ConvexOptional themeProfileId = const ConvexOptional.absent(), }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:createWithInitialPage.args.clientProtocolVersion', + ), if (folderPublicId.isPresent) 'folderPublicId': ConvexString(folderPublicId.value), 'initialPageIsAttack': ConvexBoolean(initialPageIsAttack), @@ -5145,9 +5249,14 @@ ConvexValue decodeStrategiesCreateWithInitialPageResult(ConvexValue value) => ); ConvexObject encodeStrategiesDeleteArgs({ + required double clientProtocolVersion, required double expectedRevision, required String strategyPublicId, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:delete.args.clientProtocolVersion', + ), 'expectedRevision': _encodeNumber( expectedRevision, 'strategies.js:delete.args.expectedRevision', @@ -5200,10 +5309,15 @@ List decodeStrategiesListSharedWithMeResult( .toList(growable: false); ConvexObject encodeStrategiesMoveArgs({ + required double clientProtocolVersion, required double expectedRevision, ConvexOptional folderPublicId = const ConvexOptional.absent(), required String strategyPublicId, }) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:move.args.clientProtocolVersion', + ), 'expectedRevision': _encodeNumber( expectedRevision, 'strategies.js:move.args.expectedRevision', @@ -5220,6 +5334,7 @@ ConvexObject encodeStrategiesUpdateArgs({ ConvexOptional clearThemeOverridePalette = const ConvexOptional.absent(), ConvexOptional clearThemeProfileId = const ConvexOptional.absent(), + required double clientProtocolVersion, required double expectedRevision, ConvexOptional mapData = const ConvexOptional.absent(), ConvexOptional name = const ConvexOptional.absent(), @@ -5235,6 +5350,10 @@ ConvexObject encodeStrategiesUpdateArgs({ 'clearThemeOverridePalette': ConvexBoolean(clearThemeOverridePalette.value), if (clearThemeProfileId.isPresent) 'clearThemeProfileId': ConvexBoolean(clearThemeProfileId.value), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:update.args.clientProtocolVersion', + ), 'expectedRevision': _encodeNumber( expectedRevision, 'strategies.js:update.args.expectedRevision', @@ -5270,7 +5389,14 @@ ConvexObject encodeStrategyGetShellArgs({required String strategyPublicId}) => StrategyGetShellResult decodeStrategyGetShellResult(ConvexValue value) => StrategyGetShellResult.decode(value, 'strategy.js:getShell.returns'); -ConvexObject encodeUsersEnsureCurrentUserArgs() => ConvexObject({}); +ConvexObject encodeUsersEnsureCurrentUserArgs({ + required double clientProtocolVersion, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'users.js:ensureCurrentUser.args.clientProtocolVersion', + ), +}); FoldersDeleteResult decodeUsersEnsureCurrentUserResult(ConvexValue value) => FoldersDeleteResult.decode(value, 'users.js:ensureCurrentUser.returns'); diff --git a/lib/collab/generated/icarus_convex_api.dart b/lib/collab/generated/icarus_convex_api.dart index 05456b11..53c1d989 100644 --- a/lib/collab/generated/icarus_convex_api.dart +++ b/lib/collab/generated/icarus_convex_api.dart @@ -198,6 +198,7 @@ final class _ElementsModule implements ElementsModule { abstract interface class FoldersModule { Future create({ + required double clientProtocolVersion, ConvexOptional color = const ConvexOptional.absent(), ConvexOptional customColorValue = const ConvexOptional.absent(), ConvexOptional iconCodePoint = const ConvexOptional.absent(), @@ -208,12 +209,16 @@ abstract interface class FoldersModule { ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), required String publicId, }); - Future delete({required String folderPublicId}); + Future delete({ + required double clientProtocolVersion, + required String folderPublicId, + }); ConvexQuery> listTree({ ConvexOptional scope = const ConvexOptional.absent(), }); Future move({ + required double clientProtocolVersion, required String folderPublicId, ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), }); @@ -221,6 +226,7 @@ abstract interface class FoldersModule { ConvexOptional clearCustomColorValue = const ConvexOptional.absent(), ConvexOptional clearIconFontFamily = const ConvexOptional.absent(), ConvexOptional clearIconFontPackage = const ConvexOptional.absent(), + required double clientProtocolVersion, ConvexOptional color = const ConvexOptional.absent(), ConvexOptional customColorValue = const ConvexOptional.absent(), required String folderPublicId, @@ -237,6 +243,7 @@ final class _FoldersModule implements FoldersModule { final ConvexTransport _transport; @override Future create({ + required double clientProtocolVersion, ConvexOptional color = const ConvexOptional.absent(), ConvexOptional customColorValue = const ConvexOptional.absent(), ConvexOptional iconCodePoint = const ConvexOptional.absent(), @@ -248,6 +255,7 @@ final class _FoldersModule implements FoldersModule { required String publicId, }) { final args = encodeFoldersCreateArgs( + clientProtocolVersion: clientProtocolVersion, color: color, customColorValue: customColorValue, iconCodePoint: iconCodePoint, @@ -265,8 +273,14 @@ final class _FoldersModule implements FoldersModule { } @override - Future delete({required String folderPublicId}) { - final args = encodeFoldersDeleteArgs(folderPublicId: folderPublicId); + Future delete({ + required double clientProtocolVersion, + required String folderPublicId, + }) { + final args = encodeFoldersDeleteArgs( + clientProtocolVersion: clientProtocolVersion, + folderPublicId: folderPublicId, + ); return _invoke( () => _transport.mutation('folders:delete', args), decodeFoldersDeleteResult, @@ -289,10 +303,12 @@ final class _FoldersModule implements FoldersModule { @override Future move({ + required double clientProtocolVersion, required String folderPublicId, ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), }) { final args = encodeFoldersMoveArgs( + clientProtocolVersion: clientProtocolVersion, folderPublicId: folderPublicId, parentFolderPublicId: parentFolderPublicId, ); @@ -307,6 +323,7 @@ final class _FoldersModule implements FoldersModule { ConvexOptional clearCustomColorValue = const ConvexOptional.absent(), ConvexOptional clearIconFontFamily = const ConvexOptional.absent(), ConvexOptional clearIconFontPackage = const ConvexOptional.absent(), + required double clientProtocolVersion, ConvexOptional color = const ConvexOptional.absent(), ConvexOptional customColorValue = const ConvexOptional.absent(), required String folderPublicId, @@ -320,6 +337,7 @@ final class _FoldersModule implements FoldersModule { clearCustomColorValue: clearCustomColorValue, clearIconFontFamily: clearIconFontFamily, clearIconFontPackage: clearIconFontPackage, + clientProtocolVersion: clientProtocolVersion, color: color, customColorValue: customColorValue, folderPublicId: folderPublicId, @@ -359,6 +377,7 @@ abstract interface class ImagesModule { Future completeUpload({ required String assetPublicId, ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, ConvexOptional etag = const ConvexOptional.absent(), ConvexOptional fileExtension = const ConvexOptional.absent(), ConvexOptional height = const ConvexOptional.absent(), @@ -373,11 +392,13 @@ abstract interface class ImagesModule { }); Future deleteAssetRef({ required String assetPublicId, + required double clientProtocolVersion, required String strategyPublicId, }); Future generateUploadUrl({ required String assetPublicId, ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, required String fileExtension, ConvexOptional height = const ConvexOptional.absent(), required String mimeType, @@ -400,6 +421,7 @@ final class _ImagesModule implements ImagesModule { Future completeUpload({ required String assetPublicId, ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, ConvexOptional etag = const ConvexOptional.absent(), ConvexOptional fileExtension = const ConvexOptional.absent(), ConvexOptional height = const ConvexOptional.absent(), @@ -415,6 +437,7 @@ final class _ImagesModule implements ImagesModule { final args = encodeImagesCompleteUploadArgs( assetPublicId: assetPublicId, byteSize: byteSize, + clientProtocolVersion: clientProtocolVersion, etag: etag, fileExtension: fileExtension, height: height, @@ -435,10 +458,12 @@ final class _ImagesModule implements ImagesModule { @override Future deleteAssetRef({ required String assetPublicId, + required double clientProtocolVersion, required String strategyPublicId, }) { final args = encodeImagesDeleteAssetRefArgs( assetPublicId: assetPublicId, + clientProtocolVersion: clientProtocolVersion, strategyPublicId: strategyPublicId, ); return _invoke( @@ -451,6 +476,7 @@ final class _ImagesModule implements ImagesModule { Future generateUploadUrl({ required String assetPublicId, ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, required String fileExtension, ConvexOptional height = const ConvexOptional.absent(), required String mimeType, @@ -460,6 +486,7 @@ final class _ImagesModule implements ImagesModule { final args = encodeImagesGenerateUploadUrlArgs( assetPublicId: assetPublicId, byteSize: byteSize, + clientProtocolVersion: clientProtocolVersion, fileExtension: fileExtension, height: height, mimeType: mimeType, @@ -507,6 +534,7 @@ final class _ImagesModule implements ImagesModule { abstract interface class InvitesModule { Future create({ + required double clientProtocolVersion, ConvexOptional expiresAt = const ConvexOptional.absent(), required InvitesCreateArgsRole role, required String strategyPublicId, @@ -516,8 +544,12 @@ abstract interface class InvitesModule { ConvexOptional strategyPublicId = const ConvexOptional.absent(), ConvexOptional token = const ConvexOptional.absent(), }); - Future redeem({required String token}); + Future redeem({ + required double clientProtocolVersion, + required String token, + }); Future revoke({ + required double clientProtocolVersion, required String strategyPublicId, required String token, }); @@ -528,12 +560,14 @@ final class _InvitesModule implements InvitesModule { final ConvexTransport _transport; @override Future create({ + required double clientProtocolVersion, ConvexOptional expiresAt = const ConvexOptional.absent(), required InvitesCreateArgsRole role, required String strategyPublicId, required String token, }) { final args = encodeInvitesCreateArgs( + clientProtocolVersion: clientProtocolVersion, expiresAt: expiresAt, role: role, strategyPublicId: strategyPublicId, @@ -563,8 +597,14 @@ final class _InvitesModule implements InvitesModule { } @override - Future redeem({required String token}) { - final args = encodeInvitesRedeemArgs(token: token); + Future redeem({ + required double clientProtocolVersion, + required String token, + }) { + final args = encodeInvitesRedeemArgs( + clientProtocolVersion: clientProtocolVersion, + token: token, + ); return _invoke( () => _transport.mutation('invites:redeem', args), decodeInvitesRedeemResult, @@ -573,10 +613,12 @@ final class _InvitesModule implements InvitesModule { @override Future revoke({ + required double clientProtocolVersion, required String strategyPublicId, required String token, }) { final args = encodeInvitesRevokeArgs( + clientProtocolVersion: clientProtocolVersion, strategyPublicId: strategyPublicId, token: token, ); @@ -695,6 +737,7 @@ final class _PageModule implements PageModule { abstract interface class PagesModule { Future add({ + required double clientProtocolVersion, required double expectedRevision, required bool isAttack, ConvexOptional isAutoNamed = const ConvexOptional.absent(), @@ -706,6 +749,7 @@ abstract interface class PagesModule { required String strategyPublicId, }); Future delete({ + required double clientProtocolVersion, required double expectedRevision, required String pagePublicId, required String strategyPublicId, @@ -714,6 +758,7 @@ abstract interface class PagesModule { required String strategyPublicId, }); Future rename({ + required double clientProtocolVersion, required double expectedRevision, ConvexOptional isAutoNamed = const ConvexOptional.absent(), required String name, @@ -721,6 +766,7 @@ abstract interface class PagesModule { required String strategyPublicId, }); Future reorder({ + required double clientProtocolVersion, required double expectedRevision, required List orderedPagePublicIds, required String strategyPublicId, @@ -732,6 +778,7 @@ final class _PagesModule implements PagesModule { final ConvexTransport _transport; @override Future add({ + required double clientProtocolVersion, required double expectedRevision, required bool isAttack, ConvexOptional isAutoNamed = const ConvexOptional.absent(), @@ -743,6 +790,7 @@ final class _PagesModule implements PagesModule { required String strategyPublicId, }) { final args = encodePagesAddArgs( + clientProtocolVersion: clientProtocolVersion, expectedRevision: expectedRevision, isAttack: isAttack, isAutoNamed: isAutoNamed, @@ -760,11 +808,13 @@ final class _PagesModule implements PagesModule { @override Future delete({ + required double clientProtocolVersion, required double expectedRevision, required String pagePublicId, required String strategyPublicId, }) { final args = encodePagesDeleteArgs( + clientProtocolVersion: clientProtocolVersion, expectedRevision: expectedRevision, pagePublicId: pagePublicId, strategyPublicId: strategyPublicId, @@ -792,6 +842,7 @@ final class _PagesModule implements PagesModule { @override Future rename({ + required double clientProtocolVersion, required double expectedRevision, ConvexOptional isAutoNamed = const ConvexOptional.absent(), required String name, @@ -799,6 +850,7 @@ final class _PagesModule implements PagesModule { required String strategyPublicId, }) { final args = encodePagesRenameArgs( + clientProtocolVersion: clientProtocolVersion, expectedRevision: expectedRevision, isAutoNamed: isAutoNamed, name: name, @@ -813,11 +865,13 @@ final class _PagesModule implements PagesModule { @override Future reorder({ + required double clientProtocolVersion, required double expectedRevision, required List orderedPagePublicIds, required String strategyPublicId, }) { final args = encodePagesReorderArgs( + clientProtocolVersion: clientProtocolVersion, expectedRevision: expectedRevision, orderedPagePublicIds: orderedPagePublicIds, strategyPublicId: strategyPublicId, @@ -831,6 +885,7 @@ final class _PagesModule implements PagesModule { abstract interface class SharesModule { Future create({ + required double clientProtocolVersion, required InvitesCreateArgsRole role, required String targetPublicId, required SharesCreateArgsTargetType targetType, @@ -840,8 +895,12 @@ abstract interface class SharesModule { required String targetPublicId, required SharesCreateArgsTargetType targetType, }); - Future redeem({required String token}); + Future redeem({ + required double clientProtocolVersion, + required String token, + }); Future revoke({ + required double clientProtocolVersion, required String targetPublicId, required SharesCreateArgsTargetType targetType, required String token, @@ -853,12 +912,14 @@ final class _SharesModule implements SharesModule { final ConvexTransport _transport; @override Future create({ + required double clientProtocolVersion, required InvitesCreateArgsRole role, required String targetPublicId, required SharesCreateArgsTargetType targetType, required String token, }) { final args = encodeSharesCreateArgs( + clientProtocolVersion: clientProtocolVersion, role: role, targetPublicId: targetPublicId, targetType: targetType, @@ -888,8 +949,14 @@ final class _SharesModule implements SharesModule { } @override - Future redeem({required String token}) { - final args = encodeSharesRedeemArgs(token: token); + Future redeem({ + required double clientProtocolVersion, + required String token, + }) { + final args = encodeSharesRedeemArgs( + clientProtocolVersion: clientProtocolVersion, + token: token, + ); return _invoke( () => _transport.mutation('shares:redeem', args), decodeSharesRedeemResult, @@ -898,11 +965,13 @@ final class _SharesModule implements SharesModule { @override Future revoke({ + required double clientProtocolVersion, required String targetPublicId, required SharesCreateArgsTargetType targetType, required String token, }) { final args = encodeSharesRevokeArgs( + clientProtocolVersion: clientProtocolVersion, targetPublicId: targetPublicId, targetType: targetType, token: token, @@ -916,6 +985,7 @@ final class _SharesModule implements SharesModule { abstract interface class StrategiesModule { Future create({ + required double clientProtocolVersion, ConvexOptional folderPublicId = const ConvexOptional.absent(), required String mapData, required String name, @@ -928,6 +998,7 @@ abstract interface class StrategiesModule { ConvexOptional themeProfileId = const ConvexOptional.absent(), }); Future createWithInitialPage({ + required double clientProtocolVersion, ConvexOptional folderPublicId = const ConvexOptional.absent(), required bool initialPageIsAttack, ConvexOptional initialPageIsAutoNamed = const ConvexOptional.absent(), @@ -947,6 +1018,7 @@ abstract interface class StrategiesModule { ConvexOptional themeProfileId = const ConvexOptional.absent(), }); Future delete({ + required double clientProtocolVersion, required double expectedRevision, required String strategyPublicId, }); @@ -960,6 +1032,7 @@ abstract interface class StrategiesModule { }); ConvexQuery> listSharedWithMe(); Future move({ + required double clientProtocolVersion, required double expectedRevision, ConvexOptional folderPublicId = const ConvexOptional.absent(), required String strategyPublicId, @@ -968,6 +1041,7 @@ abstract interface class StrategiesModule { ConvexOptional clearThemeOverridePalette = const ConvexOptional.absent(), ConvexOptional clearThemeProfileId = const ConvexOptional.absent(), + required double clientProtocolVersion, required double expectedRevision, ConvexOptional mapData = const ConvexOptional.absent(), ConvexOptional name = const ConvexOptional.absent(), @@ -986,6 +1060,7 @@ final class _StrategiesModule implements StrategiesModule { final ConvexTransport _transport; @override Future create({ + required double clientProtocolVersion, ConvexOptional folderPublicId = const ConvexOptional.absent(), required String mapData, required String name, @@ -998,6 +1073,7 @@ final class _StrategiesModule implements StrategiesModule { ConvexOptional themeProfileId = const ConvexOptional.absent(), }) { final args = encodeStrategiesCreateArgs( + clientProtocolVersion: clientProtocolVersion, folderPublicId: folderPublicId, mapData: mapData, name: name, @@ -1013,6 +1089,7 @@ final class _StrategiesModule implements StrategiesModule { @override Future createWithInitialPage({ + required double clientProtocolVersion, ConvexOptional folderPublicId = const ConvexOptional.absent(), required bool initialPageIsAttack, ConvexOptional initialPageIsAutoNamed = const ConvexOptional.absent(), @@ -1032,6 +1109,7 @@ final class _StrategiesModule implements StrategiesModule { ConvexOptional themeProfileId = const ConvexOptional.absent(), }) { final args = encodeStrategiesCreateWithInitialPageArgs( + clientProtocolVersion: clientProtocolVersion, folderPublicId: folderPublicId, initialPageIsAttack: initialPageIsAttack, initialPageIsAutoNamed: initialPageIsAutoNamed, @@ -1052,10 +1130,12 @@ final class _StrategiesModule implements StrategiesModule { @override Future delete({ + required double clientProtocolVersion, required double expectedRevision, required String strategyPublicId, }) { final args = encodeStrategiesDeleteArgs( + clientProtocolVersion: clientProtocolVersion, expectedRevision: expectedRevision, strategyPublicId: strategyPublicId, ); @@ -1111,11 +1191,13 @@ final class _StrategiesModule implements StrategiesModule { @override Future move({ + required double clientProtocolVersion, required double expectedRevision, ConvexOptional folderPublicId = const ConvexOptional.absent(), required String strategyPublicId, }) { final args = encodeStrategiesMoveArgs( + clientProtocolVersion: clientProtocolVersion, expectedRevision: expectedRevision, folderPublicId: folderPublicId, strategyPublicId: strategyPublicId, @@ -1131,6 +1213,7 @@ final class _StrategiesModule implements StrategiesModule { ConvexOptional clearThemeOverridePalette = const ConvexOptional.absent(), ConvexOptional clearThemeProfileId = const ConvexOptional.absent(), + required double clientProtocolVersion, required double expectedRevision, ConvexOptional mapData = const ConvexOptional.absent(), ConvexOptional name = const ConvexOptional.absent(), @@ -1145,6 +1228,7 @@ final class _StrategiesModule implements StrategiesModule { final args = encodeStrategiesUpdateArgs( clearThemeOverridePalette: clearThemeOverridePalette, clearThemeProfileId: clearThemeProfileId, + clientProtocolVersion: clientProtocolVersion, expectedRevision: expectedRevision, mapData: mapData, name: name, @@ -1201,7 +1285,9 @@ final class _StrategyModule implements StrategyModule { } abstract interface class UsersModule { - Future ensureCurrentUser(); + Future ensureCurrentUser({ + required double clientProtocolVersion, + }); ConvexQuery me(); } @@ -1209,8 +1295,12 @@ final class _UsersModule implements UsersModule { const _UsersModule(this._transport); final ConvexTransport _transport; @override - Future ensureCurrentUser() { - final args = encodeUsersEnsureCurrentUserArgs(); + Future ensureCurrentUser({ + required double clientProtocolVersion, + }) { + final args = encodeUsersEnsureCurrentUserArgs( + clientProtocolVersion: clientProtocolVersion, + ); return _invoke( () => _transport.mutation('users:ensureCurrentUser', args), decodeUsersEnsureCurrentUserResult, diff --git a/lib/collab/strategy_capabilities.dart b/lib/collab/strategy_capabilities.dart new file mode 100644 index 00000000..e970cd67 --- /dev/null +++ b/lib/collab/strategy_capabilities.dart @@ -0,0 +1,70 @@ +class StrategyCapabilities { + const StrategyCapabilities({ + required this.canRenameStrategy, + required this.canDeleteStrategy, + required this.canDuplicateStrategy, + required this.canMoveStrategy, + required this.canEditPages, + required this.canAddPage, + required this.canRenamePage, + required this.canDeletePage, + required this.canReorderPages, + required this.canCreateFolder, + required this.canEditFolder, + required this.canDeleteFolder, + required this.canMoveFolder, + }); + + final bool canRenameStrategy; + final bool canDeleteStrategy; + final bool canDuplicateStrategy; + final bool canMoveStrategy; + final bool canEditPages; + final bool canAddPage; + final bool canRenamePage; + final bool canDeletePage; + final bool canReorderPages; + final bool canCreateFolder; + final bool canEditFolder; + final bool canDeleteFolder; + final bool canMoveFolder; + + factory StrategyCapabilities.fullAccess() { + return const StrategyCapabilities( + canRenameStrategy: true, + canDeleteStrategy: true, + canDuplicateStrategy: true, + canMoveStrategy: true, + canEditPages: true, + canAddPage: true, + canRenamePage: true, + canDeletePage: true, + canReorderPages: true, + canCreateFolder: true, + canEditFolder: true, + canDeleteFolder: true, + canMoveFolder: true, + ); + } + + factory StrategyCapabilities.fromCloudRole(String? role) { + final normalized = role ?? 'viewer'; + final canEdit = normalized == 'owner' || normalized == 'editor'; + final isOwner = normalized == 'owner'; + return StrategyCapabilities( + canRenameStrategy: canEdit, + canDeleteStrategy: isOwner, + canDuplicateStrategy: canEdit, + canMoveStrategy: isOwner, + canEditPages: canEdit, + canAddPage: canEdit, + canRenamePage: canEdit, + canDeletePage: canEdit, + canReorderPages: canEdit, + canCreateFolder: isOwner, + canEditFolder: isOwner, + canDeleteFolder: isOwner, + canMoveFolder: isOwner, + ); + } +} diff --git a/lib/config/cloud_build_config.dart b/lib/config/cloud_build_config.dart new file mode 100644 index 00000000..5d1d2597 --- /dev/null +++ b/lib/config/cloud_build_config.dart @@ -0,0 +1,147 @@ +const String developmentConvexDeploymentUrl = + 'https://majestic-eel-413.convex.cloud'; +const String developmentConvexClientId = 'dev:majestic-eel-413'; +const String _developmentConvexHost = 'majestic-eel-413.convex.cloud'; +final RegExp _convexDeploymentHost = RegExp( + r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.convex\.cloud$', +); + +const String _compiledCloudEnvironment = String.fromEnvironment( + 'ICARUS_CLOUD_ENVIRONMENT', +); +const String _compiledConvexDeploymentUrl = String.fromEnvironment( + 'ICARUS_CONVEX_DEPLOYMENT_URL', +); +const String _compiledConvexClientId = String.fromEnvironment( + 'ICARUS_CONVEX_CLIENT_ID', +); + +class CloudBuildConfig { + const CloudBuildConfig._({ + required this.environment, + required this.deploymentUrl, + required this.clientId, + }); + + final String environment; + final String deploymentUrl; + final String clientId; + + factory CloudBuildConfig.fromEnvironment({required bool isReleaseMode}) { + return CloudBuildConfig.forBuild( + isReleaseMode: isReleaseMode, + environment: _compiledCloudEnvironment, + deploymentUrl: _compiledConvexDeploymentUrl, + clientId: _compiledConvexClientId, + ); + } + + factory CloudBuildConfig.forBuild({ + required bool isReleaseMode, + String environment = '', + String deploymentUrl = '', + String clientId = '', + }) { + if (environment.trim().isEmpty) { + if (isReleaseMode) { + throw StateError( + 'Release builds require an explicit ICARUS_CLOUD_ENVIRONMENT. ' + "Use 'development' for CI or prerelease, or 'production' with " + 'production Convex values.', + ); + } + environment = 'development'; + } + + return CloudBuildConfig.resolve( + environment: environment, + deploymentUrl: deploymentUrl, + clientId: clientId, + ); + } + + factory CloudBuildConfig.resolve({ + required String environment, + String deploymentUrl = '', + String clientId = '', + }) { + final resolvedEnvironment = environment.trim().toLowerCase(); + final resolvedDeploymentUrl = deploymentUrl.trim(); + final resolvedClientId = clientId.trim(); + + switch (resolvedEnvironment) { + case 'development': + if (resolvedDeploymentUrl.isEmpty != resolvedClientId.isEmpty) { + throw StateError( + 'Development Convex overrides must include both ' + 'ICARUS_CONVEX_DEPLOYMENT_URL and ICARUS_CONVEX_CLIENT_ID.', + ); + } + + final developmentUrl = resolvedDeploymentUrl.isEmpty + ? developmentConvexDeploymentUrl + : resolvedDeploymentUrl; + _requireHttpsUrl(developmentUrl); + return CloudBuildConfig._( + environment: resolvedEnvironment, + deploymentUrl: developmentUrl, + clientId: resolvedClientId.isEmpty + ? developmentConvexClientId + : resolvedClientId, + ); + case 'production': + if (resolvedDeploymentUrl.isEmpty || resolvedClientId.isEmpty) { + throw StateError( + 'Production cloud builds require explicit ' + 'ICARUS_CONVEX_DEPLOYMENT_URL and ICARUS_CONVEX_CLIENT_ID values.', + ); + } + final productionUri = _requireProductionDeploymentUrl( + resolvedDeploymentUrl, + ); + if (productionUri.host == _developmentConvexHost || + resolvedClientId == developmentConvexClientId) { + throw StateError( + 'Production cloud builds cannot use the Icarus development ' + 'Convex deployment.', + ); + } + return CloudBuildConfig._( + environment: resolvedEnvironment, + deploymentUrl: resolvedDeploymentUrl, + clientId: resolvedClientId, + ); + default: + throw StateError( + "Unsupported ICARUS_CLOUD_ENVIRONMENT '$environment'. " + "Use 'development' or 'production'.", + ); + } + } + + static void _requireHttpsUrl(String value) { + final uri = Uri.tryParse(value); + if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) { + throw StateError('Convex deployment URL must be an absolute HTTPS URL.'); + } + } + + static Uri _requireProductionDeploymentUrl(String value) { + final uri = Uri.tryParse(value); + final hasCanonicalOrigin = uri != null && + uri.scheme == 'https' && + _convexDeploymentHost.hasMatch(uri.host.toLowerCase()) && + uri.userInfo.isEmpty && + !uri.hasPort && + (uri.path.isEmpty || uri.path == '/') && + !uri.hasQuery && + !uri.hasFragment; + if (!hasCanonicalOrigin) { + throw StateError( + 'Production Convex deployment URL must be a canonical ' + 'https://.convex.cloud URL.', + ); + } + return uri; + } +} diff --git a/lib/const/hive_boxes.dart b/lib/const/hive_boxes.dart index b43352d6..72c2e6c3 100644 --- a/lib/const/hive_boxes.dart +++ b/lib/const/hive_boxes.dart @@ -5,5 +5,6 @@ class HiveBoxNames { static const appPreferencesBox = "app_preferences_box"; static const favoriteAgentsBox = "favorite_agents_box"; static const strategyOutboxBox = "strategy_outbox_box"; + static const cloudMediaOutboxBox = "cloud_media_outbox_box"; static const pinnedItemsBox = "pinned_items_box"; } diff --git a/lib/interactive_map.dart b/lib/interactive_map.dart index d9a578b0..7a57535f 100644 --- a/lib/interactive_map.dart +++ b/lib/interactive_map.dart @@ -7,6 +7,7 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; import 'package:icarus/providers/canvas_resize_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -123,6 +124,11 @@ class _InteractiveMapState extends ConsumerState { @override Widget build(BuildContext context) { bool isAttack = ref.watch(mapProvider).isAttack; + final canEditPages = ref.watch( + currentStrategyCapabilitiesProvider.select( + (capabilities) => capabilities.canEditPages, + ), + ); final transitionPresentation = ref.watch( transitionProvider.select( (state) => ( @@ -322,16 +328,26 @@ class _InteractiveMapState extends ConsumerState { ), ), Positioned.fill( - child: transitionPresentation.hideView - ? SizedBox.shrink() - : Opacity( - opacity: ref.watch( - interactionStateProvider) == - InteractionState.lineUpPlacing - ? 0.2 - : 1.0, - child: PlacedWidgetBuilder(), - ), + child: IgnorePointer( + key: const ValueKey( + 'strategy-canvas-object-editor', + ), + ignoring: !canEditPages, + child: ExcludeFocus( + excluding: !canEditPages, + child: transitionPresentation.hideView + ? SizedBox.shrink() + : Opacity( + opacity: ref.watch( + interactionStateProvider) == + InteractionState + .lineUpPlacing + ? 0.2 + : 1.0, + child: PlacedWidgetBuilder(), + ), + ), + ), ), Positioned.fill( child: transitionPresentation.hideView @@ -368,21 +384,30 @@ class _InteractiveMapState extends ConsumerState { InteractionState.lineUpPlacing ? 0.2 : 1.0; - return Opacity( - opacity: - transitionOpacity * lineUpOpacity, - child: Transform.flip( - flipX: !isAttack, - flipY: !isAttack, - child: InteractivePainter()), + return IgnorePointer( + key: const ValueKey( + 'strategy-canvas-drawing-editor', + ), + ignoring: !canEditPages, + child: Opacity( + opacity: + transitionOpacity * lineUpOpacity, + child: Transform.flip( + flipX: !isAttack, + flipY: !isAttack, + child: InteractivePainter()), + ), ); }, ), ), if (ref.watch(interactionStateProvider) == InteractionState.lineUpPlacing) - const Positioned.fill( - child: LineupPositionWidget(), + Positioned.fill( + child: IgnorePointer( + ignoring: !canEditPages, + child: const LineupPositionWidget(), + ), ), ], ), @@ -395,15 +420,21 @@ class _InteractiveMapState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - HoveredMapItemNameCard(), - DeleteArea(), + children: [ + const HoveredMapItemNameCard(), + IgnorePointer( + ignoring: !canEditPages, + child: const DeleteArea(), + ), ], ), ), Align( alignment: Alignment.bottomRight, - child: const LineupControlButtons(), + child: IgnorePointer( + ignoring: !canEditPages, + child: const LineupControlButtons(), + ), ), ], ), diff --git a/lib/main.dart b/lib/main.dart index 7b03bb4d..11ec3f71 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,9 +4,10 @@ import 'dart:ui' show PlatformDispatcher; import 'package:app_links/app_links.dart'; import 'package:icarus/collab/convex_client.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; import 'package:icarus/collab/durable_strategy_outbox.dart'; import 'package:custom_mouse_cursor/custom_mouse_cursor.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show kIsWeb, kReleaseMode; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -23,6 +24,7 @@ import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/const/routes.dart'; import 'package:icarus/const/second_instance_args.dart'; import 'package:icarus/const/settings.dart' show Settings; +import 'package:icarus/config/cloud_build_config.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/auth_provider.dart'; @@ -30,6 +32,7 @@ import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/share_link_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/map_provider.dart'; @@ -38,7 +41,9 @@ import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/share/share_link_format.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/services/cloud_sign_out_coordinator.dart'; import 'package:icarus/services/discord_presence_service.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; import 'package:icarus/strategy/strategy_models.dart'; @@ -112,9 +117,16 @@ Future main(List args) async { () async { WidgetsFlutterBinding.ensureInitialized(); - appProviderContainer = ProviderContainer(); + appProviderContainer = ProviderContainer(overrides: [ + guardedSignOutRequestProvider.overrideWith( + (ref) => ref.watch(cloudSignOutRequestProvider), + ), + ]); await _initializePersistedDebugLog(); _installGlobalErrorHandlers(); + final cloudBuildConfig = CloudBuildConfig.fromEnvironment( + isReleaseMode: kReleaseMode, + ); await registerDeepLinkProtocol('icarus'); await _initializeDeepLinkHandling(); @@ -152,6 +164,8 @@ Future main(List args) async { await Hive.openBox(HiveBoxNames.favoriteAgentsBox); await Hive.openBox(HiveBoxNames.strategyOutboxBox); await prepareDurableStrategyOutbox(); + await Hive.openBox(HiveBoxNames.cloudMediaOutboxBox); + await prepareDurableCloudMediaOutbox(); await Hive.openBox(HiveBoxNames.pinnedItemsBox); await Hive.openBox(AnalyticsService.storageBoxName); @@ -160,10 +174,10 @@ Future main(List args) async { await StrategyMigrator.migrateAllStrategies(); await ConvexClient.initialize( - const ConvexConfig( - deploymentUrl: 'https://majestic-eel-413.convex.cloud', - clientId: 'dev:majestic-eel-413', - operationTimeout: Duration(seconds: 30), + ConvexConfig( + deploymentUrl: cloudBuildConfig.deploymentUrl, + clientId: cloudBuildConfig.clientId, + operationTimeout: const Duration(seconds: 30), healthCheckQuery: defaultConvexHealthCheckQuery, ), ); @@ -387,6 +401,7 @@ class _MyAppState extends ConsumerState { void initState() { super.initState(); ref.read(authProvider); + ref.read(strategyOpQueueProvider); ref.read(cloudMediaUploadQueueProvider); ref.read(cloudMediaCacheProvider); diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index d298d9fc..8d25dbdb 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/app_navigator.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -1251,7 +1252,9 @@ class AuthProvider extends Notifier { await reinitializeConvexAuth(source: 'incident_prompt_retry'); break; case _AuthIncidentAction.signOut: - await signOut(); + if (navCtx.mounted) { + await ref.read(guardedSignOutRequestProvider)(navCtx); + } break; case _AuthIncidentAction.dismiss: case null: diff --git a/lib/providers/collab/active_page_live_sync_models.dart b/lib/providers/collab/active_page_live_sync_models.dart index c885b1bc..5dd552d9 100644 --- a/lib/providers/collab/active_page_live_sync_models.dart +++ b/lib/providers/collab/active_page_live_sync_models.dart @@ -137,6 +137,7 @@ class ActivePageOverlayEntry { required this.desiredSortIndex, required this.deletion, required this.baseRevision, + required this.baseDeleted, required this.dirtyAt, }); @@ -145,7 +146,8 @@ class ActivePageOverlayEntry { final Object? desiredPayload; final int? desiredSortIndex; final bool deletion; - final int baseRevision; + final int? baseRevision; + final bool baseDeleted; final DateTime dirtyAt; ActivePageOverlayEntry copyWith({ @@ -153,6 +155,7 @@ class ActivePageOverlayEntry { int? desiredSortIndex, bool? deletion, int? baseRevision, + bool? baseDeleted, DateTime? dirtyAt, }) { return ActivePageOverlayEntry( @@ -162,6 +165,7 @@ class ActivePageOverlayEntry { desiredSortIndex: desiredSortIndex ?? this.desiredSortIndex, deletion: deletion ?? this.deletion, baseRevision: baseRevision ?? this.baseRevision, + baseDeleted: baseDeleted ?? this.baseDeleted, dirtyAt: dirtyAt ?? this.dirtyAt, ); } diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart index 40fe2e98..67fe97c6 100644 --- a/lib/providers/collab/active_page_live_sync_provider.dart +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -70,12 +70,19 @@ final activePageLiveSyncProvider = ); class ActivePageLiveSyncNotifier extends Notifier { + // Live reads can advance while local work blocks rehydration. Outbound diffs + // must stay based on the server state that was actually loaded into canvas. + final Map _hydratedBaseByEntityKey = {}; + final Set _remoteAdoptionPending = {}; + @override ActivePageLiveSyncState build() { return const ActivePageLiveSyncState(); } void reset() { + _hydratedBaseByEntityKey.clear(); + _remoteAdoptionPending.clear(); state = const ActivePageLiveSyncState(); } @@ -87,8 +94,13 @@ class ActivePageLiveSyncNotifier extends Notifier { required String? strategyPublicId, required String? activePageId, }) { + final strategyChanged = strategyPublicId != state.strategyPublicId; final contextChanged = strategyPublicId != state.strategyPublicId || activePageId != state.activePageId; + if (strategyChanged) { + _hydratedBaseByEntityKey.clear(); + _remoteAdoptionPending.clear(); + } state = state.copyWith( strategyPublicId: strategyPublicId, activePageId: activePageId, @@ -119,11 +131,26 @@ class ActivePageLiveSyncNotifier extends Notifier { void markPageHydrated({ required String strategyPublicId, required String pageId, + required RemoteEditorSnapshot snapshot, }) { setContext(strategyPublicId: strategyPublicId, activePageId: pageId); + final remoteEntities = snapshot.header.publicId != strategyPublicId || + snapshot.activePage?.page.publicId != pageId + ? const {} + : _normalizedRemoteEntities(snapshot, pageId); + _hydratedBaseByEntityKey.removeWhere((key, _) => key.pageId == pageId); + _hydratedBaseByEntityKey.addAll(remoteEntities); + _remoteAdoptionPending.removeWhere((key) => key.pageId == pageId); + final remoteRevisions = Map.from( + state.remoteBaseRevisionByEntity, + )..removeWhere((key, _) => key.pageId == pageId); + for (final entry in remoteEntities.entries) { + remoteRevisions[entry.key] = entry.value.revision; + } state = state.copyWith( hydratedPageId: pageId, hydratedEntityKeys: _normalizedLocalEntities(pageId).keys.toSet(), + remoteBaseRevisionByEntity: remoteRevisions, ); } @@ -132,7 +159,160 @@ class ActivePageLiveSyncNotifier extends Notifier { } void recordAckBatch(List intents) { - state = state.copyWith(lastAckBatch: intents); + final overlays = Map.from( + state.overlayByEntityKey, + ); + final remoteRevisions = Map.from( + state.remoteBaseRevisionByEntity, + ); + for (final intent in intents) { + final revision = intent.ack.appliedRevision; + final key = intent.entityKey; + if (revision == null || key.pageId != state.hydratedPageId) { + continue; + } + final accepted = _normalizedAcceptedEntity( + key: key, + op: intent.op, + revision: revision, + ); + if (accepted == null) continue; + + _hydratedBaseByEntityKey[key] = accepted; + remoteRevisions[key] = revision; + final overlay = overlays[key]; + if (overlay != null) { + overlays[key] = overlay.copyWith( + baseRevision: revision, + baseDeleted: accepted.deleted, + ); + } + } + state = state.copyWith( + overlayByEntityKey: overlays, + remoteBaseRevisionByEntity: remoteRevisions, + lastAckBatch: intents, + ); + } + + _NormalizedEntity? _normalizedAcceptedEntity({ + required EntitySyncKey key, + required StrategyOp op, + required int revision, + }) { + final previous = _hydratedBaseByEntityKey[key]; + return switch (op) { + PagePatchOp(:final payload) => _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.pageDescriptor, + payload: payload, + sortIndex: null, + revision: revision, + deleted: false, + ), + PageContentPatchOp(:final settings) => _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.pageContent, + payload: {'settings': settings}, + sortIndex: null, + revision: revision, + deleted: false, + ), + ElementAddOp(:final payload, :final sortIndex) || + ElementPatchOp(:final payload?, :final sortIndex?) => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: payload, + sortIndex: sortIndex, + revision: revision, + deleted: false, + ), + ElementPatchOp(:final payload, :final sortIndex) when previous != null => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: payload ?? previous.payload, + sortIndex: sortIndex ?? previous.sortIndex, + revision: revision, + deleted: false, + ), + ElementReorderOp(:final sortIndex) when previous != null => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: previous.payload, + sortIndex: sortIndex, + revision: revision, + deleted: previous.deleted, + ), + ElementDeleteOp() when previous != null => _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: previous.payload, + sortIndex: previous.sortIndex, + revision: revision, + deleted: true, + ), + LineupAddOp(:final payload, :final sortIndex) || + LineupPatchOp(:final payload?, :final sortIndex?) => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: payload, + sortIndex: sortIndex, + revision: revision, + deleted: false, + ), + LineupPatchOp(:final payload, :final sortIndex) when previous != null => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: payload ?? previous.payload, + sortIndex: sortIndex ?? previous.sortIndex, + revision: revision, + deleted: false, + ), + LineupReorderOp(:final sortIndex) when previous != null => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: previous.payload, + sortIndex: sortIndex, + revision: revision, + deleted: previous.deleted, + ), + LineupDeleteOp() when previous != null => _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: previous.payload, + sortIndex: previous.sortIndex, + revision: revision, + deleted: true, + ), + _ => null, + }; + } + + /// Stops local projection and reconciliation for explicitly discarded work + /// until the affected page has loaded the authoritative remote snapshot. + void adoptRemoteForEntities( + Set entityKeys, { + String? hydratedPageId, + }) { + if (entityKeys.isEmpty) return; + final overlays = Map.from( + state.overlayByEntityKey, + ); + for (final key in entityKeys) { + overlays.remove(key); + if (key.pageId != null && key.pageId != hydratedPageId) { + _remoteAdoptionPending.add(key); + } else { + _remoteAdoptionPending.remove(key); + } + } + state = state.copyWith(overlayByEntityKey: overlays); } Map? syncLocalPage({ @@ -159,40 +339,69 @@ class ActivePageLiveSyncNotifier extends Notifier { final queueState = ref.read(strategyOpQueueProvider); final remoteEntities = _normalizedRemoteEntities(snapshot, pageId); final localEntities = _normalizedLocalEntities(pageId); - final remoteRevisions = Map.from( - state.remoteBaseRevisionByEntity, - ); - - for (final entry in remoteEntities.entries) { - remoteRevisions[entry.key] = entry.value.revision; - } final pageKeys = { ...remoteEntities.keys, ...localEntities.keys, + ..._hydratedBaseByEntityKey.keys.where((key) => key.pageId == pageId), ...state.overlayByEntityKey.keys.where((key) => key.pageId == pageId), ...queueState.queuedByEntityKey.keys.where((key) => key.pageId == pageId), ...queueState.inFlightByEntityKey.keys .where((key) => key.pageId == pageId), ...queueState.successorByEntityKey.keys .where((key) => key.pageId == pageId), + ..._remoteAdoptionPending.where((key) => key.pageId == pageId), }; final nextOverlay = Map.from( state.overlayByEntityKey, ); + final retainedDesiredOps = {}; for (final key in pageKeys) { + if (_remoteAdoptionPending.contains(key)) { + nextOverlay.remove(key); + _debugLog('overlay.remove $key reason=adopting_remote'); + continue; + } final remote = remoteEntities[key]; final local = localEntities[key]; + final hydratedBase = _hydratedBaseByEntityKey[key]; final hasQueued = queueState.queuedByEntityKey.containsKey(key); final hasInFlight = queueState.inFlightByEntityKey.containsKey(key); + final hasSuccessor = queueState.successorByEntityKey.containsKey(key); final existingOverlay = state.overlayByEntityKey[key]; + final retainedOp = queueState.successorByEntityKey[key]?.pending.op ?? + queueState.inFlightByEntityKey[key]?.pending.op ?? + queueState.queuedByEntityKey[key]?.pending.op; - final shouldPreserveTouched = hasQueued || hasInFlight; + final shouldPreserveTouched = hasQueued || hasInFlight || hasSuccessor; final matchesRemote = _entitiesEquivalent(local, remote); + final matchesHydratedBase = _entitiesEquivalent(local, hydratedBase); + final shouldUseRetainedIntent = hasQueued || + (!hasInFlight && hasSuccessor) || + (local == null && hydratedBase == null); + + // A restored queue entry has no in-memory overlay. If the canvas still + // matches its hydrated base, the durable op is the only local intent and + // must remain desired until it lands or the user changes that entity. + if (existingOverlay == null && + retainedOp != null && + shouldUseRetainedIntent && + matchesHydratedBase) { + retainedDesiredOps[key] = retainedOp; + _debugLog('overlay.keep $key reason=durable_queue_only'); + continue; + } - if (matchesRemote && !hasQueued && !hasInFlight) { + if (matchesHydratedBase && !shouldPreserveTouched) { + if (nextOverlay.remove(key) != null) { + _debugLog('overlay.remove $key reason=unchanged_since_hydration'); + } + continue; + } + + if (matchesRemote && !shouldPreserveTouched) { if (nextOverlay.remove(key) != null) { _debugLog('overlay.remove $key reason=matched_remote'); } @@ -210,7 +419,8 @@ class ActivePageLiveSyncNotifier extends Notifier { final overlay = _overlayFromDesiredEntity( key: key, desired: local, - baseRevision: remote?.revision ?? existingOverlay?.baseRevision ?? 0, + hydratedBase: hydratedBase, + existingOverlay: existingOverlay, ); nextOverlay[key] = overlay; _debugLog('overlay.keep $key reason=pending_reconciliation'); @@ -223,23 +433,32 @@ class ActivePageLiveSyncNotifier extends Notifier { continue; } - if (local == null && remote != null) { - final wasHydratedLocally = state.hydratedEntityKeys.contains(key); - if (!wasHydratedLocally && + if (local == null) { + if (hydratedBase == null && existingOverlay == null && !shouldPreserveTouched) { _debugLog( - 'overlay.skip $key reason=remote_not_yet_hydrated_locally', + 'overlay.skip $key reason=not_in_hydrated_base', ); continue; } + final entityType = existingOverlay?.entityType ?? + hydratedBase?.overlayEntityType ?? + remote?.overlayEntityType ?? + key.overlayType; + if (entityType == null) { + _debugLog('overlay.skip $key reason=unsupported_entity_key'); + continue; + } final overlay = ActivePageOverlayEntry( entityKey: key, - entityType: remote.overlayEntityType, + entityType: entityType, desiredPayload: null, desiredSortIndex: null, deletion: true, - baseRevision: remote.revision, + baseRevision: existingOverlay?.baseRevision ?? hydratedBase?.revision, + baseDeleted: + existingOverlay?.baseDeleted ?? hydratedBase?.deleted ?? false, dirtyAt: DateTime.now(), ); nextOverlay[key] = overlay; @@ -247,20 +466,21 @@ class ActivePageLiveSyncNotifier extends Notifier { continue; } - if (local != null) { - final overlay = _overlayFromDesiredEntity( - key: key, - desired: local, - baseRevision: remote?.revision ?? existingOverlay?.baseRevision ?? 0, - ); - nextOverlay[key] = overlay; - _debugLog( - 'overlay.upsert $key deletion=false baseRevision=${overlay.baseRevision}', - ); - } + final overlay = _overlayFromDesiredEntity( + key: key, + desired: local, + hydratedBase: hydratedBase, + existingOverlay: existingOverlay, + ); + nextOverlay[key] = overlay; + _debugLog( + 'overlay.upsert $key deletion=false baseRevision=${overlay.baseRevision}', + ); } - final desiredOpsByEntityKey = {}; + final desiredOpsByEntityKey = { + ...retainedDesiredOps, + }; for (final entry in nextOverlay.entries) { final key = entry.key; if (key.pageId != pageId) { @@ -269,15 +489,15 @@ class ActivePageLiveSyncNotifier extends Notifier { final remote = remoteEntities[key]; final overlay = entry.value; if (_overlayMatchesRemote(overlay, remote) && - !_needsPageDescriptorSuccessor( + !_needsSuccessor( + pageId: pageId, key: key, overlay: overlay, queueState: queueState, )) { continue; } - final op = _strategyOpFromOverlay( - pageId: pageId, overlay: overlay, remote: remote); + final op = _strategyOpFromOverlay(pageId: pageId, overlay: overlay); if (op != null) { desiredOpsByEntityKey[key] = op; } @@ -286,7 +506,6 @@ class ActivePageLiveSyncNotifier extends Notifier { state = state.copyWith( strategyPublicId: strategyPublicId, activePageId: pageId, - remoteBaseRevisionByEntity: remoteRevisions, overlayByEntityKey: nextOverlay, ); @@ -639,7 +858,8 @@ class ActivePageLiveSyncNotifier extends Notifier { ActivePageOverlayEntry _overlayFromDesiredEntity({ required EntitySyncKey key, required _NormalizedEntity desired, - required int baseRevision, + required _NormalizedEntity? hydratedBase, + required ActivePageOverlayEntry? existingOverlay, }) { return ActivePageOverlayEntry( entityKey: key, @@ -647,7 +867,9 @@ class ActivePageLiveSyncNotifier extends Notifier { desiredPayload: desired.payload, desiredSortIndex: desired.sortIndex, deletion: desired.deleted, - baseRevision: baseRevision, + baseRevision: existingOverlay?.baseRevision ?? hydratedBase?.revision, + baseDeleted: + existingOverlay?.baseDeleted ?? hydratedBase?.deleted ?? false, dirtyAt: DateTime.now(), ); } @@ -655,18 +877,21 @@ class ActivePageLiveSyncNotifier extends Notifier { StrategyOp? _strategyOpFromOverlay({ required String pageId, required ActivePageOverlayEntry overlay, - required _NormalizedEntity? remote, }) { final entityId = overlay.entityKey.entityId; switch (overlay.entityType) { case ActivePageOverlayEntityType.pageDescriptor: + final baseRevision = overlay.baseRevision; + if (baseRevision == null) return null; return PagePatchOp( opId: const Uuid().v4(), pagePublicId: pageId, payload: Map.from(overlay.desiredPayload as Map), - expectedPageRevision: remote?.revision ?? overlay.baseRevision, + expectedPageRevision: baseRevision, ); case ActivePageOverlayEntityType.pageContent: + final baseRevision = overlay.baseRevision; + if (baseRevision == null) return null; final payload = Map.from( overlay.desiredPayload as Map, ); @@ -674,30 +899,34 @@ class ActivePageLiveSyncNotifier extends Notifier { opId: const Uuid().v4(), pagePublicId: pageId, settings: Map.from(payload['settings'] as Map), - expectedPageContentRevision: remote?.revision ?? overlay.baseRevision, + expectedPageContentRevision: baseRevision, ); case ActivePageOverlayEntityType.element: if (entityId == null) { return null; } if (overlay.deletion) { + // A delete after a local add has no revision until that add lands. + // Zero cannot land early; the outbox rebases the successor from the + // accepted add acknowledgment. + final baseRevision = overlay.baseRevision ?? 0; return ElementDeleteOp( opId: const Uuid().v4(), elementPublicId: entityId, pagePublicId: pageId, - expectedElementRevision: remote?.revision ?? overlay.baseRevision, + expectedElementRevision: baseRevision, ); } final payload = Map.from(overlay.desiredPayload as Map); - return remote == null || remote.deleted + return overlay.baseRevision == null || overlay.baseDeleted ? ElementAddOp( opId: const Uuid().v4(), elementPublicId: entityId, pagePublicId: pageId, payload: payload, sortIndex: overlay.desiredSortIndex ?? 0, - expectedElementRevision: remote?.revision, + expectedElementRevision: overlay.baseRevision, ) : ElementPatchOp( opId: const Uuid().v4(), @@ -705,30 +934,31 @@ class ActivePageLiveSyncNotifier extends Notifier { pagePublicId: pageId, payload: payload, sortIndex: overlay.desiredSortIndex, - expectedElementRevision: remote.revision, + expectedElementRevision: overlay.baseRevision!, ); case ActivePageOverlayEntityType.lineup: if (entityId == null) { return null; } if (overlay.deletion) { + final baseRevision = overlay.baseRevision ?? 0; return LineupDeleteOp( opId: const Uuid().v4(), lineupPublicId: entityId, pagePublicId: pageId, - expectedLineupRevision: remote?.revision ?? overlay.baseRevision, + expectedLineupRevision: baseRevision, ); } final payload = Map.from(overlay.desiredPayload as Map); - return remote == null || remote.deleted + return overlay.baseRevision == null || overlay.baseDeleted ? LineupAddOp( opId: const Uuid().v4(), lineupPublicId: entityId, pagePublicId: pageId, payload: payload, sortIndex: overlay.desiredSortIndex ?? 0, - expectedLineupRevision: remote?.revision, + expectedLineupRevision: overlay.baseRevision, ) : LineupPatchOp( opId: const Uuid().v4(), @@ -736,7 +966,7 @@ class ActivePageLiveSyncNotifier extends Notifier { pagePublicId: pageId, payload: payload, sortIndex: overlay.desiredSortIndex, - expectedLineupRevision: remote.revision, + expectedLineupRevision: overlay.baseRevision!, ); } } @@ -755,24 +985,31 @@ class ActivePageLiveSyncNotifier extends Notifier { overlay.desiredSortIndex == remote.sortIndex; } - bool _needsPageDescriptorSuccessor({ + bool _needsSuccessor({ + required String pageId, required EntitySyncKey key, required ActivePageOverlayEntry overlay, required StrategyOpQueueState queueState, }) { - if (key.kind != EntitySyncKeyKind.pageDescriptor || - queueState.successorByEntityKey.containsKey(key)) { + if (queueState.successorByEntityKey.containsKey(key)) { return false; } - final desiredPayload = overlay.desiredPayload; - if (desiredPayload is! Map) return false; - final desiredSide = desiredPayload['isAttack']; - if (desiredSide is! bool) return false; final predecessor = queueState.inFlightByEntityKey[key]?.pending.op ?? queueState.queuedByEntityKey[key]?.pending.op; - if (predecessor is! PagePatchOp) return false; - return predecessor.payload['isAttack'] != desiredSide; + if (predecessor == null) return false; + final desired = _strategyOpFromOverlay(pageId: pageId, overlay: overlay); + return desired != null && !_opsEquivalent(predecessor, desired); + } + + bool _opsEquivalent(StrategyOp left, StrategyOp right) { + return left.kind == right.kind && + left.entityType == right.entityType && + left.entityPublicId == right.entityPublicId && + left.pagePublicId == right.pagePublicId && + cloudJsonEquivalent(left.payload, right.payload) && + left.sortIndex == right.sortIndex && + left.expectedRevision == right.expectedRevision; } bool _entitiesEquivalent( diff --git a/lib/providers/collab/cloud_media_upload_queue_provider.dart b/lib/providers/collab/cloud_media_upload_queue_provider.dart index 0b9794ec..b909cbf7 100644 --- a/lib/providers/collab/cloud_media_upload_queue_provider.dart +++ b/lib/providers/collab/cloud_media_upload_queue_provider.dart @@ -1,18 +1,21 @@ import 'dart:async'; import 'dart:io'; -import 'package:icarus/collab/convex_client.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; @@ -24,10 +27,19 @@ class CloudMediaUploadQueueState { const CloudMediaUploadQueueState({ required this.jobs, required this.isProcessing, + this.loadIssues = const [], + this.durableLoaded = true, + this.durabilityError, }); final List jobs; final bool isProcessing; + final List loadIssues; + final bool durableLoaded; + final String? durabilityError; + + bool get outboxIsReliable => + durableLoaded && loadIssues.isEmpty && durabilityError == null; List jobsForStrategy(String? strategyPublicId) { if (strategyPublicId == null) { @@ -51,10 +63,17 @@ class CloudMediaUploadQueueState { CloudMediaUploadQueueState copyWith({ List? jobs, bool? isProcessing, + String? durabilityError, + bool clearDurabilityError = false, }) { return CloudMediaUploadQueueState( jobs: jobs ?? this.jobs, isProcessing: isProcessing ?? this.isProcessing, + loadIssues: loadIssues, + durableLoaded: durableLoaded, + durabilityError: clearDurabilityError + ? null + : (durabilityError ?? this.durabilityError), ); } } @@ -76,8 +95,21 @@ final cloudMediaUploadQueueProvider = CloudMediaUploadQueueNotifier.new, ); +typedef CloudMediaReferenceSnapshotLoader = Future + Function(String strategyPublicId); + +final cloudMediaReferenceSnapshotLoaderProvider = + Provider( + (ref) => ref.watch(convexStrategyRepositoryProvider).fetchFullSnapshot, +); + +final cloudMediaAccountIdProvider = Provider( + (ref) => ref.watch(authProvider.select((state) => state.user?.id)), +); + class CloudMediaUploadQueueNotifier extends Notifier { + static const Duration _blockedRetryDelay = Duration(seconds: 30); Timer? _retryTimer; Timer? _uploadCompletionDismissTimer; ToastificationItem? _uploadProgressToast; @@ -87,14 +119,30 @@ class CloudMediaUploadQueueNotifier final Map _uploadBytesTotalByJob = {}; final Set _uploadCompletingJobs = {}; final Set _uploadCompletedJobs = {}; - final Map _jobsById = {}; + final Map _jobsByStorageKey = {}; + final Set _restoredStagedJobIds = {}; + bool _disposed = false; + late DurableCloudMediaOutboxStore _store; ConvexStrategyRepository get _repo => ref.read(convexStrategyRepositoryProvider); @override CloudMediaUploadQueueState build() { + _store = ref.read(durableCloudMediaOutboxStoreProvider); + final loaded = _store.load(); + _jobsByStorageKey.addEntries( + loaded.jobs.map( + (job) => MapEntry(durableCloudMediaOutboxStorageKey(job), job), + ), + ); + _restoredStagedJobIds.addAll( + loaded.jobs + .where((job) => !job.referenceDurable) + .map(durableCloudMediaOutboxStorageKey), + ); ref.onDispose(() { + _disposed = true; _retryTimer?.cancel(); _uploadCompletionDismissTimer?.cancel(); final toast = _uploadProgressToast; @@ -114,9 +162,36 @@ class CloudMediaUploadQueueNotifier } }); - return const CloudMediaUploadQueueState( - jobs: [], + ref.listen(cloudMediaAccountIdProvider, (previous, next) { + if (previous == next) return; + _refreshState(); + if (next != null && next.isNotEmpty) { + retryNow(ignoreBackoff: true); + } + }); + + ref.listen>(convexConnectionProvider, (previous, next) { + final reconnected = + previous?.valueOrNull != true && next.valueOrNull == true; + if (reconnected) { + retryNow(ignoreBackoff: true); + } + }); + + if (_jobsByStorageKey.isNotEmpty) { + scheduleMicrotask(() { + if (!_disposed) retryNow(ignoreBackoff: true); + }); + } + + return CloudMediaUploadQueueState( + jobs: _readJobs(), isProcessing: false, + loadIssues: loaded.issues, + durableLoaded: true, + durabilityError: loaded.issues.isEmpty + ? null + : 'The media outbox contains unreadable saved work.', ); } @@ -140,10 +215,12 @@ class CloudMediaUploadQueueNotifier return; } + final accountId = _requireActiveAccountId(); final normalizedExtension = normalizeImageExtension(fileExtension ?? ''); await _upsertJob( CloudMediaUploadJob( jobId: imagePublicId, + accountId: accountId, strategyPublicId: resolvedStrategyId, assetPublicId: imagePublicId, fileExtension: normalizedExtension, @@ -151,14 +228,14 @@ class CloudMediaUploadQueueNotifier width: width, height: height, state: CloudMediaJobState.pendingUpload, + referenceDurable: false, attempts: 0, updatedAt: DateTime.now(), ), ); _logMedia( - 'enqueue.placed_image ${_describeJob(_getJob(imagePublicId))}', + 'enqueue.placed_image_staged ${_describeJob(_getJob(imagePublicId))}', ); - retryNow(ignoreBackoff: true); } Future enqueueJobForLocalFile({ @@ -169,10 +246,12 @@ class CloudMediaUploadQueueNotifier int? width, int? height, }) async { + final accountId = _requireActiveAccountId(); final normalizedExtension = normalizeImageExtension(fileExtension); await _upsertJob( CloudMediaUploadJob( jobId: assetPublicId, + accountId: accountId, strategyPublicId: strategyPublicId, assetPublicId: assetPublicId, fileExtension: normalizedExtension, @@ -180,6 +259,7 @@ class CloudMediaUploadQueueNotifier width: width, height: height, state: CloudMediaJobState.pendingUpload, + referenceDurable: false, attempts: 0, updatedAt: DateTime.now(), ), @@ -192,27 +272,123 @@ class CloudMediaUploadQueueNotifier required String strategyPublicId, required Iterable images, }) async { - for (final image in images) { - final normalizedExtension = normalizeImageExtension(image.fileExtension); - await _upsertJob( + final accountId = _requireActiveAccountId(); + final imageList = images.toList(growable: false); + final jobs = [ + for (final image in imageList) CloudMediaUploadJob( jobId: image.id, + accountId: accountId, strategyPublicId: strategyPublicId, assetPublicId: image.id, - fileExtension: normalizedExtension, - mimeType: mimeTypeForImageExtension(normalizedExtension), + fileExtension: normalizeImageExtension(image.fileExtension), + mimeType: mimeTypeForImageExtension(image.fileExtension), state: CloudMediaJobState.pendingUpload, + referenceDurable: false, attempts: 0, updatedAt: DateTime.now(), ), - ); + ]; + await _upsertJobsAtomically(jobs); + for (final image in imageList) { _logMedia('enqueue.lineup_image ${_describeJob(_getJob(image.id))}'); } - retryNow(ignoreBackoff: true); + } + + Future commitStagedMediaReferences({ + required String strategyPublicId, + required Iterable assetPublicIds, + }) async { + final accountId = _requireActiveAccountId(); + final requestedIds = assetPublicIds.toSet(); + final staged = _readJobs() + .where( + (job) => + job.accountId == accountId && + job.strategyPublicId == strategyPublicId && + requestedIds.contains(job.assetPublicId) && + !job.referenceDurable, + ) + .toList(growable: false); + if (staged.isEmpty) return; + + await ref + .read(strategyProvider.notifier) + .notifyCloudMutation(flushImmediately: false); + final opQueueState = ref.read(strategyOpQueueProvider); + if (!opQueueState.outboxIsReliable) { + throw StateError( + 'The strategy reference could not be saved to the durable outbox.', + ); + } + final durableOps = ref.read(durableStrategyOutboxStoreProvider).load(); + if (durableOps.issues.isNotEmpty) { + throw StateError( + 'The strategy reference outbox contains unreadable saved work.', + ); + } + final pendingOps = [ + for (final record in durableOps.records) + if (record.accountId == accountId && + record.strategyPublicId == strategyPublicId) ...[ + record.pending.op, + if (record.successorPending case final successor?) successor.op, + ], + ]; + final missingReferences = staged + .where( + (job) => !pendingOps.any( + (op) => _opReferencesAsset(op, job.assetPublicId), + ), + ) + .toList(growable: false); + if (missingReferences.isNotEmpty) { + RemoteFullStrategySnapshot? serverSnapshot; + if (ref.read(authProvider).isConvexUserReady && + ref.read(convexConnectionSnapshotProvider)) { + try { + serverSnapshot = + await ref.read(cloudMediaReferenceSnapshotLoaderProvider)( + strategyPublicId, + ); + } catch (_) { + serverSnapshot = null; + } + } + if (serverSnapshot == null || + missingReferences.any( + (job) => + !_snapshotReferencesAsset(serverSnapshot!, job.assetPublicId), + )) { + _scheduleRetryForNextEligibleJob( + minimumDelay: _blockedRetryDelay, + ); + throw StateError( + 'The strategy reference was not admitted to the durable outbox.', + ); + } + } + + if (ref.read(cloudMediaAccountIdProvider) != accountId) { + throw StateError('The active account changed before media was queued.'); + } + + await _putJobsAtomically([ + for (final job in staged) + job.copyWith( + referenceDurable: true, + updatedAt: DateTime.now(), + ), + ]); + _refreshState(); + await retryNow(ignoreBackoff: true); } Future retryNow({bool ignoreBackoff = false}) async { + if (_disposed) return; _retryTimer?.cancel(); + await _reconcileStagedJobReferences(); + if (_disposed) return; _logMedia( 'retry_now ignoreBackoff=$ignoreBackoff jobs=${_readJobs().length}', ); @@ -220,7 +396,6 @@ class CloudMediaUploadQueueNotifier } Future setActiveStrategy(String? strategyPublicId) async { - _retryTimer?.cancel(); _refreshState(); if (strategyPublicId != null) { await retryNow(ignoreBackoff: true); @@ -266,49 +441,11 @@ class CloudMediaUploadQueueNotifier .where((job) => job.strategyPublicId == strategyPublicId) .toList(growable: false); for (final job in jobs) { - _deleteJob(job.jobId); + await _deleteJob(job); } _refreshState(); } - Future cancelUpload(String assetPublicId) async { - final job = _getJob(assetPublicId); - if (job == null) { - return; - } - _deleteJob(job.jobId); - _refreshState(); - - try { - final file = await PlacedImageProvider.getImageFile( - strategyID: job.strategyPublicId, - imageID: job.assetPublicId, - fileExtension: job.fileExtension, - ); - if (await file.exists()) { - await file.delete(); - } - } catch (error, stackTrace) { - AppErrorReporter.reportError( - 'Failed to delete canceled media upload file.', - error: error, - stackTrace: stackTrace, - source: 'cloud_media.upload_queue', - ); - } - - ref.read(placedImageProvider.notifier).removeImage(job.assetPublicId); - } - - Future cancelUploadsForStrategy(String strategyPublicId) async { - final jobs = _readJobs() - .where((job) => job.strategyPublicId == strategyPublicId) - .toList(growable: false); - for (final job in jobs) { - await cancelUpload(job.assetPublicId); - } - } - Future _processNextJob({bool ignoreBackoff = false}) async { if (state.isProcessing) { return; @@ -325,14 +462,15 @@ class CloudMediaUploadQueueNotifier state = state.copyWith(isProcessing: true); _logMedia('process.start ${_describeJob(nextJob)}'); + late final bool madeProgress; try { - await _processJob(nextJob); + madeProgress = await _processJob(nextJob); } finally { _refreshState(isProcessing: false); _logMedia('process.finish jobs=${state.jobs.length}'); } - if (_readJobs().isNotEmpty) { + if (madeProgress && _readJobs().isNotEmpty) { // Only bypass backoff for the initial user-triggered kick. Follow-up // attempts must honor retry timing so transient attach failures do not // hammer Convex in a tight loop. @@ -345,6 +483,9 @@ class CloudMediaUploadQueueNotifier ..sort((a, b) => a.updatedAt.compareTo(b.updatedAt)); final now = DateTime.now(); for (final job in jobs) { + if (!job.referenceDurable) { + continue; + } if (ignoreBackoff || !_nextAttemptAt(job).isAfter(now)) { return job; } @@ -352,7 +493,11 @@ class CloudMediaUploadQueueNotifier return null; } - Future _processJob(CloudMediaUploadJob job) async { + Future _processJob(CloudMediaUploadJob job) async { + if (!_belongsToActiveAccount(job)) { + _logMedia('process.blocked account_mismatch ${_describeJob(job)}'); + return false; + } final mode = ref.read(cloudCollabModeProvider); final auth = ref.read(authProvider); if (!mode.featureFlagEnabled || mode.forceLocalFallback) { @@ -360,33 +505,35 @@ class CloudMediaUploadQueueNotifier 'process.blocked featureFlag=${mode.featureFlagEnabled} ' 'forceLocalFallback=${mode.forceLocalFallback} ${_describeJob(job)}', ); - _scheduleRetryForNextEligibleJob(); - return; + _scheduleRetryForNextEligibleJob(minimumDelay: _blockedRetryDelay); + return false; } if (!auth.isAuthenticated || !auth.isConvexUserReady || auth.hasActiveAuthIncident || - !ConvexClient.instance.isConnected) { + !ref.read(convexConnectionSnapshotProvider)) { _logMedia( 'process.blocked auth=${auth.isAuthenticated} ' 'userReady=${auth.isConvexUserReady} ' 'authIncident=${auth.hasActiveAuthIncident} ' - 'connected=${ConvexClient.instance.isConnected} ' + 'connected=${ref.read(convexConnectionSnapshotProvider)} ' '${_describeJob(job)}', ); - _scheduleRetryForNextEligibleJob(); - return; + _scheduleRetryForNextEligibleJob(minimumDelay: _blockedRetryDelay); + return false; } if (!job.hasUploadedRemoteObject) { await _uploadJobBlob(job); - return; + return true; } await _attachUploadedJob(job); + return true; } Future _uploadJobBlob(CloudMediaUploadJob job) async { + if (!_belongsToActiveAccount(job)) return; try { _logMedia('upload.local_lookup ${_describeJob(job)}'); final file = await PlacedImageProvider.getImageFile( @@ -397,6 +544,9 @@ class CloudMediaUploadQueueNotifier if (!await file.exists()) { _logMedia( 'upload.local_missing path=${file.path} ${_describeJob(job)}'); + if (await _deleteJobWhenReferenceIsGone(job)) { + return; + } await _markJobFailed( job, 'Local media file is missing.', @@ -406,6 +556,10 @@ class CloudMediaUploadQueueNotifier } final byteSize = await file.length(); + if (!_belongsToActiveAccount(job)) { + _logMedia('upload.deferred account_changed ${_describeJob(job)}'); + return; + } _setUploadByteProgress( job.jobId, sentBytes: 0, @@ -438,6 +592,11 @@ class CloudMediaUploadQueueNotifier ); } + if (!_belongsToActiveAccount(job)) { + _logMedia('upload.deferred account_changed ${_describeJob(job)}'); + return; + } + _logMedia('upload.put.start bytes=$byteSize ${_describeJob(job)}'); final response = await _putFileWithProgress( job: job, @@ -456,8 +615,7 @@ class CloudMediaUploadQueueNotifier 'etag=${response.headers['etag']} ${_describeJob(job)}', ); - _putJob( - job.jobId, + await _putJob( job.copyWith( provider: intent.provider, uploadId: intent.uploadId, @@ -487,6 +645,57 @@ class CloudMediaUploadQueueNotifier } } + Future _deleteJobWhenReferenceIsGone( + CloudMediaUploadJob job, + ) async { + final durableOps = ref.read(durableStrategyOutboxStoreProvider).load(); + if (durableOps.issues.isNotEmpty) return false; + final pendingReferences = durableOps.records.any( + (record) => + record.accountId == job.accountId && + record.strategyPublicId == job.strategyPublicId && + (_opReferencesAsset(record.pending.op, job.assetPublicId) || + (record.successorPending != null && + _opReferencesAsset( + record.successorPending!.op, + job.assetPublicId, + ))), + ); + if (pendingReferences || + !ref.read(authProvider).isConvexUserReady || + !ref.read(convexConnectionSnapshotProvider)) { + return false; + } + + late final RemoteFullStrategySnapshot snapshot; + try { + snapshot = await ref.read(cloudMediaReferenceSnapshotLoaderProvider)( + job.strategyPublicId, + ); + } catch (error) { + _logMedia( + 'missing_source.reference_check_deferred ' + 'strategy=${job.strategyPublicId} error=$error', + ); + return false; + } + final current = _getJob(job.jobId); + if (!_belongsToActiveAccount(job) || + current == null || + current.updatedAt != job.updatedAt || + _snapshotReferencesAsset(snapshot, job.assetPublicId)) { + return false; + } + + await _deleteJob(job); + _refreshState(); + _logMedia( + 'missing_source.removed_unreferenced job=${job.jobId} ' + 'strategy=${job.strategyPublicId}', + ); + return true; + } + Future _putFileWithProgress({ required CloudMediaUploadJob job, required File file, @@ -530,6 +739,7 @@ class CloudMediaUploadQueueNotifier } Future _attachUploadedJob(CloudMediaUploadJob job) async { + if (!_belongsToActiveAccount(job)) return; try { _logMedia('attach.start ${_describeJob(job)}'); if ((job.provider == 'r2' || job.uploadId != null) && @@ -556,7 +766,7 @@ class CloudMediaUploadQueueNotifier width: job.width, height: job.height, ); - _deleteJob(job.jobId); + await _deleteJob(job); if (_uploadProgressToast != null) { _markUploadComplete(job.jobId); } @@ -578,8 +788,7 @@ class CloudMediaUploadQueueNotifier String errorMessage, { required bool showToast, }) async { - _putJob( - job.jobId, + await _putJob( job.copyWith( state: CloudMediaJobState.failed, attempts: job.attempts + 1, @@ -609,10 +818,18 @@ class CloudMediaUploadQueueNotifier return job.updatedAt.add(Duration(seconds: cappedSeconds)); } - void _scheduleRetryForNextEligibleJob() { + void _scheduleRetryForNextEligibleJob({Duration? minimumDelay}) { _retryTimer?.cancel(); - final jobs = _readJobs(); + final allJobs = _readJobs(); + final jobs = + allJobs.where((job) => job.referenceDurable).toList(growable: false); if (jobs.isEmpty) { + if (allJobs.any((job) => !job.referenceDurable)) { + _retryTimer = Timer( + minimumDelay ?? _blockedRetryDelay, + () => unawaited(retryNow()), + ); + } return; } @@ -629,69 +846,323 @@ class CloudMediaUploadQueueNotifier return; } - final delay = + var delay = earliest.isAfter(now) ? earliest.difference(now) : Duration.zero; + if (minimumDelay != null && delay < minimumDelay) { + delay = minimumDelay; + } _logMedia( 'retry.scheduled delayMs=${delay.inMilliseconds} jobs=${jobs.length}'); - _retryTimer = Timer(delay, () { - unawaited(_processNextJob(ignoreBackoff: false)); - }); + _retryTimer = Timer(delay, () => unawaited(retryNow())); } - Future _upsertJob(CloudMediaUploadJob nextJob) async { - final existing = _getJob(nextJob.jobId); - if (existing != null) { - final merged = existing.isFailed - ? existing.copyWith( - strategyPublicId: nextJob.strategyPublicId, - assetPublicId: nextJob.assetPublicId, - fileExtension: nextJob.fileExtension, - mimeType: nextJob.mimeType, - width: nextJob.width, - height: nextJob.height, - byteSize: nextJob.byteSize, - state: CloudMediaJobState.pendingUpload, - attempts: 0, - provider: null, - uploadId: null, - objectKey: null, - storageId: null, - etag: null, - uploadUrlExpiresAt: null, - lastError: null, - updatedAt: DateTime.now(), - ) - : existing.copyWith( - strategyPublicId: nextJob.strategyPublicId, - assetPublicId: nextJob.assetPublicId, - fileExtension: nextJob.fileExtension, - mimeType: nextJob.mimeType, - width: nextJob.width, - height: nextJob.height, - byteSize: nextJob.byteSize, + Future _reconcileStagedJobReferences() async { + final accountId = ref.read(cloudMediaAccountIdProvider); + if (accountId == null || accountId.isEmpty) return; + final staged = _readJobs() + .where( + (job) => job.accountId == accountId && !job.referenceDurable, + ) + .toList(growable: false); + if (staged.isEmpty) return; + + final durableOps = ref.read(durableStrategyOutboxStoreProvider).load(); + final pendingOps = + <({String accountId, String strategyPublicId, StrategyOp op})>[ + for (final record in durableOps.records) ...[ + ( + accountId: record.accountId, + strategyPublicId: record.strategyPublicId, + op: record.pending.op, + ), + if (record.successorPending case final successor?) + ( + accountId: record.accountId, + strategyPublicId: record.strategyPublicId, + op: successor.op, + ), + ], + ]; + final readyFromDurableOps = staged + .where( + (job) => pendingOps.any( + (pending) => + pending.accountId == accountId && + pending.strategyPublicId == job.strategyPublicId && + _opReferencesAsset(pending.op, job.assetPublicId), + ), + ) + .map( + (job) => job.copyWith( + referenceDurable: true, + updatedAt: DateTime.now(), + ), + ) + .toList(growable: false); + if (readyFromDurableOps.isNotEmpty) { + if (ref.read(cloudMediaAccountIdProvider) != accountId) return; + await _putJobsAtomically(readyFromDurableOps); + _refreshState(); + } + + if (durableOps.issues.isNotEmpty) return; + final unresolved = _readJobs() + .where((job) => !job.referenceDurable) + .toList(growable: false); + if (unresolved.isEmpty || + !ref.read(authProvider).isConvexUserReady || + !ref.read(convexConnectionSnapshotProvider)) { + return; + } + + final byStrategy = >{}; + for (final job in unresolved) { + (byStrategy[job.strategyPublicId] ??= []).add(job); + } + for (final entry in byStrategy.entries) { + late final RemoteFullStrategySnapshot snapshot; + try { + snapshot = await ref + .read(cloudMediaReferenceSnapshotLoaderProvider)(entry.key); + } catch (error) { + _logMedia( + 'reference_reconcile.deferred strategy=${entry.key} error=$error', + ); + continue; + } + + if (ref.read(cloudMediaAccountIdProvider) != accountId) return; + + final referenced = entry.value + .where( + (job) => _snapshotReferencesAsset(snapshot, job.assetPublicId), + ) + .map( + (job) => job.copyWith( + referenceDurable: true, updatedAt: DateTime.now(), - ); - _putJob(nextJob.jobId, merged); - } else { - _putJob(nextJob.jobId, nextJob); + ), + ) + .toList(growable: false); + await _putJobsAtomically(referenced); + if (ref.read(cloudMediaAccountIdProvider) != accountId) return; + final referencedIds = referenced.map((job) => job.jobId).toSet(); + for (final orphan in entry.value) { + if (!referencedIds.contains(orphan.jobId) && + _restoredStagedJobIds.contains( + durableCloudMediaOutboxStorageKey(orphan), + )) { + await _deleteJob(orphan); + _logMedia( + 'reference_reconcile.removed_orphan job=${orphan.jobId} ' + 'strategy=${orphan.strategyPublicId}', + ); + } + } + _refreshState(); + } + } + + bool _snapshotReferencesAsset( + RemoteFullStrategySnapshot snapshot, + String assetPublicId, + ) { + for (final elements in snapshot.elementsByPage.values) { + if (elements.any( + (element) => + !element.deleted && + element.elementType == 'image' && + element.publicId == assetPublicId, + )) { + return true; + } + } + for (final lineups in snapshot.lineupsByPage.values) { + if (lineups.any( + (lineup) => + !lineup.deleted && + _jsonContainsAssetId(lineup.payload, assetPublicId), + )) { + return true; + } + } + return false; + } + + bool _opReferencesAsset(StrategyOp op, String assetPublicId) { + if (op is ElementAddOp) { + return op.elementPublicId == assetPublicId; + } + if (op is ElementPatchOp) { + return op.elementPublicId == assetPublicId; + } + if (op is LineupAddOp) { + return _jsonContainsAssetId(op.payload, assetPublicId); } + if (op is LineupPatchOp) { + return _jsonContainsAssetId(op.payload, assetPublicId); + } + return false; + } + + bool _jsonContainsAssetId(Object? value, String assetPublicId) { + if (value is Map) { + if (value['id'] == assetPublicId) return true; + return value.values.any( + (child) => _jsonContainsAssetId(child, assetPublicId), + ); + } + if (value is Iterable) { + return value.any( + (child) => _jsonContainsAssetId(child, assetPublicId), + ); + } + return false; + } + + Future _upsertJob(CloudMediaUploadJob nextJob) async { + await _putJob(_mergeJob(nextJob)); + _refreshState(); + } + + Future _upsertJobsAtomically( + Iterable nextJobs, + ) async { + final mergedById = {}; + for (final nextJob in nextJobs) { + mergedById[nextJob.jobId] = _mergeJob(nextJob); + } + await _putJobsAtomically(mergedById.values); _refreshState(); } + CloudMediaUploadJob _mergeJob(CloudMediaUploadJob nextJob) { + final existing = + _jobsByStorageKey[durableCloudMediaOutboxStorageKey(nextJob)]; + if (existing == null) return nextJob; + if (existing.isFailed) { + return existing.copyWith( + strategyPublicId: nextJob.strategyPublicId, + assetPublicId: nextJob.assetPublicId, + fileExtension: nextJob.fileExtension, + mimeType: nextJob.mimeType, + width: nextJob.width, + height: nextJob.height, + byteSize: nextJob.byteSize, + state: nextJob.state, + referenceDurable: nextJob.referenceDurable, + attempts: 0, + provider: null, + uploadId: null, + objectKey: null, + storageId: null, + etag: null, + uploadUrlExpiresAt: null, + lastError: null, + updatedAt: DateTime.now(), + ); + } + return existing.copyWith( + strategyPublicId: nextJob.strategyPublicId, + assetPublicId: nextJob.assetPublicId, + fileExtension: nextJob.fileExtension, + mimeType: nextJob.mimeType, + width: nextJob.width, + height: nextJob.height, + byteSize: nextJob.byteSize, + referenceDurable: existing.referenceDurable || nextJob.referenceDurable, + updatedAt: DateTime.now(), + ); + } + List _readJobs() { - return _jobsById.values.toList(growable: false); + final accountId = ref.read(cloudMediaAccountIdProvider); + if (accountId == null || accountId.isEmpty) return const []; + return _jobsByStorageKey.values + .where((job) => job.accountId == accountId) + .toList(growable: false); } CloudMediaUploadJob? _getJob(String jobId) { - return _jobsById[jobId]; + final accountId = ref.read(cloudMediaAccountIdProvider); + if (accountId == null || accountId.isEmpty) return null; + final job = _jobsByStorageKey[durableCloudMediaOutboxStorageKeyFor( + accountId: accountId, + jobId: jobId, + )]; + return job != null && _belongsToActiveAccount(job) ? job : null; + } + + String _requireActiveAccountId() { + final accountId = ref.read(cloudMediaAccountIdProvider); + if (accountId == null || accountId.isEmpty) { + throw StateError( + 'Cloud media cannot be queued without an authenticated account.', + ); + } + return accountId; + } + + bool _belongsToActiveAccount(CloudMediaUploadJob job) { + final accountId = ref.read(cloudMediaAccountIdProvider); + return accountId != null && + accountId.isNotEmpty && + job.accountId == accountId; + } + + Future _putJob(CloudMediaUploadJob job) async { + try { + await _store.put(job); + _jobsByStorageKey[durableCloudMediaOutboxStorageKey(job)] = job; + } catch (error, stackTrace) { + _recordDurabilityFailure(error, stackTrace); + rethrow; + } + } + + Future _putJobsAtomically( + Iterable jobs, + ) async { + final jobList = jobs.toList(growable: false); + if (jobList.isEmpty) return; + try { + await _store.putAll(jobList); + for (final job in jobList) { + final storageKey = durableCloudMediaOutboxStorageKey(job); + _jobsByStorageKey[storageKey] = job; + if (job.referenceDurable) { + _restoredStagedJobIds.remove(storageKey); + } + } + } catch (error, stackTrace) { + _recordDurabilityFailure(error, stackTrace); + rethrow; + } } - void _putJob(String jobId, CloudMediaUploadJob job) { - _jobsById[jobId] = job; + Future _deleteJob(CloudMediaUploadJob job) async { + try { + await _store.remove(job); + final storageKey = durableCloudMediaOutboxStorageKey(job); + _jobsByStorageKey.remove(storageKey); + _restoredStagedJobIds.remove(storageKey); + } catch (error, stackTrace) { + _recordDurabilityFailure(error, stackTrace); + rethrow; + } } - void _deleteJob(String jobId) { - _jobsById.remove(jobId); + void _recordDurabilityFailure(Object error, StackTrace stackTrace) { + AppErrorReporter.reportError( + 'Failed to update the durable media outbox.', + error: error, + stackTrace: stackTrace, + source: 'cloud_media.upload_queue', + ); + state = state.copyWith( + durabilityError: 'Media work could not be saved on this device.', + isProcessing: false, + ); } void _refreshState({bool? isProcessing}) { @@ -704,11 +1175,19 @@ class CloudMediaUploadQueueNotifier void _syncUploadProgressToast() { final activeUploadCount = state.jobs - .where((job) => job.state != CloudMediaJobState.failed) + .where( + (job) => + job.state != CloudMediaJobState.failed && job.referenceDurable, + ) .length; if (activeUploadCount > 0) { if (_uploadProgressToast == null) { + final uploadHasStarted = _uploadBytesTotalByJob.isNotEmpty || + _uploadCompletingJobs.isNotEmpty; + if (!uploadHasStarted) { + return; + } _uploadCompletionDismissTimer?.cancel(); _uploadProgressTotalJobs = activeUploadCount; _uploadProgressToastState = ValueNotifier<_UploadProgressToastState>( @@ -774,7 +1253,10 @@ class CloudMediaUploadQueueNotifier double _currentUploadProgress(int totalJobs) { var progressUnits = _uploadCompletedJobs.length.toDouble(); final activeJobIds = state.jobs - .where((job) => job.state != CloudMediaJobState.failed) + .where( + (job) => + job.state != CloudMediaJobState.failed && job.referenceDurable, + ) .map((job) => job.jobId); for (final jobId in activeJobIds) { @@ -791,8 +1273,7 @@ class CloudMediaUploadQueueNotifier if (totalBytes <= 0) { continue; } - final byteProgress = - (sentBytes / totalBytes).clamp(0.0, 1.0).toDouble(); + final byteProgress = (sentBytes / totalBytes).clamp(0.0, 1.0).toDouble(); progressUnits += byteProgress * 0.9; } @@ -855,8 +1336,7 @@ class CloudMediaUploadQueueNotifier return LinearProgressIndicator( value: value, minHeight: 4, - backgroundColor: - Colors.white.withValues(alpha: 0.22), + backgroundColor: Colors.white.withValues(alpha: 0.22), valueColor: const AlwaysStoppedAnimation( Colors.white, ), @@ -911,12 +1391,20 @@ class CloudMediaUploadQueueNotifier void _publishUploadProgressToast() { final activeUploadCount = state.jobs - .where((job) => job.state != CloudMediaJobState.failed) + .where( + (job) => + job.state != CloudMediaJobState.failed && job.referenceDurable, + ) .length; - if (_uploadProgressToastState == null || activeUploadCount <= 0) { + if (activeUploadCount <= 0) { return; } - _uploadProgressToastState!.value = _buildUploadToastState(activeUploadCount); + if (_uploadProgressToastState == null) { + _syncUploadProgressToast(); + } + if (_uploadProgressToastState == null) return; + _uploadProgressToastState!.value = + _buildUploadToastState(activeUploadCount); } void _logMedia(String message) { @@ -931,6 +1419,7 @@ class CloudMediaUploadQueueNotifier return 'job=null'; } return 'job=${job.jobId} image=${job.assetPublicId} ' + 'account=${job.accountId} ' 'strategy=${job.strategyPublicId} state=${job.state.name} ' 'attempts=${job.attempts} provider=${job.provider ?? 'none'} ' 'hasUploadId=${job.uploadId != null} ' diff --git a/lib/providers/collab/cloud_sync_status_provider.dart b/lib/providers/collab/cloud_sync_status_provider.dart new file mode 100644 index 00000000..d6318d82 --- /dev/null +++ b/lib/providers/collab/cloud_sync_status_provider.dart @@ -0,0 +1,60 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +enum CloudSyncStatus { synced, editing, syncing, offline, attention } + +final cloudSyncStatusProvider = Provider((ref) { + final saveState = ref.watch(strategySaveStateProvider); + final opQueueState = ref.watch(strategyOpQueueProvider); + final mediaQueueState = ref.watch(cloudMediaUploadQueueProvider); + final strategy = ref.watch(strategyProvider); + final activeMediaJobs = mediaQueueState.jobsForStrategy( + strategy.source == StrategySource.cloud ? strategy.strategyId : null, + ); + final accountMediaErrorCount = + mediaQueueState.jobs.where((job) => job.isFailed).length; + final hasTextDrafts = ref.watch( + textDraftProvider.select((drafts) => drafts.isNotEmpty), + ); + final isConnected = ref.watch(convexConnectionProvider).valueOrNull ?? true; + + final hasDurabilityProblem = opQueueState.loadIssues.isNotEmpty || + opQueueState.hasDurabilityFailure || + mediaQueueState.loadIssues.isNotEmpty || + mediaQueueState.durabilityError != null; + if (hasDurabilityProblem) { + return CloudSyncStatus.attention; + } + if (opQueueState.needsAttention || + opQueueState.accountOutbox.needsAttention || + saveState.mediaSyncErrorCount > 0 || + accountMediaErrorCount > 0) { + return CloudSyncStatus.attention; + } + if (!isConnected) { + return CloudSyncStatus.offline; + } + if (saveState.cloudSyncError != null) { + return CloudSyncStatus.attention; + } + if (hasTextDrafts) { + return CloudSyncStatus.editing; + } + if (saveState.isSaving || + saveState.hasPendingCloudSync || + saveState.hasPendingMediaSync || + activeMediaJobs.isNotEmpty || + opQueueState.accountOutbox.hasWork || + mediaQueueState.jobs.isNotEmpty || + !opQueueState.durableLoaded || + !mediaQueueState.durableLoaded) { + return CloudSyncStatus.syncing; + } + return CloudSyncStatus.synced; +}); diff --git a/lib/providers/collab/remote_library_provider.dart b/lib/providers/collab/remote_library_provider.dart index 1ef5f6ac..20a41239 100644 --- a/lib/providers/collab/remote_library_provider.dart +++ b/lib/providers/collab/remote_library_provider.dart @@ -119,6 +119,14 @@ final cloudStrategiesProvider = } }); +final cloudStrategyNamesProvider = Provider>((ref) { + final strategies = ref.watch(cloudStrategiesProvider).valueOrNull; + if (strategies == null) return const {}; + return { + for (final entry in strategies) entry.strategy.id: entry.strategy.name, + }; +}); + bool _isInvalidFolderError(Object error) { final message = error.toString().toLowerCase(); return message.contains('folder not found') || message.contains('forbidden'); diff --git a/lib/providers/collab/strategy_capabilities_provider.dart b/lib/providers/collab/strategy_capabilities_provider.dart index 5e45c2fe..247155d0 100644 --- a/lib/providers/collab/strategy_capabilities_provider.dart +++ b/lib/providers/collab/strategy_capabilities_provider.dart @@ -1,89 +1,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/collab/strategy_capabilities.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; -class StrategyCapabilities { - const StrategyCapabilities({ - required this.canRenameStrategy, - required this.canDeleteStrategy, - required this.canDuplicateStrategy, - required this.canMoveStrategy, - required this.canEditPages, - required this.canAddPage, - required this.canRenamePage, - required this.canDeletePage, - required this.canReorderPages, - required this.canCreateFolder, - required this.canEditFolder, - required this.canDeleteFolder, - required this.canMoveFolder, - }); - - final bool canRenameStrategy; - final bool canDeleteStrategy; - final bool canDuplicateStrategy; - final bool canMoveStrategy; - final bool canEditPages; - final bool canAddPage; - final bool canRenamePage; - final bool canDeletePage; - final bool canReorderPages; - final bool canCreateFolder; - final bool canEditFolder; - final bool canDeleteFolder; - final bool canMoveFolder; - - factory StrategyCapabilities.fullAccess() { - return const StrategyCapabilities( - canRenameStrategy: true, - canDeleteStrategy: true, - canDuplicateStrategy: true, - canMoveStrategy: true, - canEditPages: true, - canAddPage: true, - canRenamePage: true, - canDeletePage: true, - canReorderPages: true, - canCreateFolder: true, - canEditFolder: true, - canDeleteFolder: true, - canMoveFolder: true, - ); - } - - factory StrategyCapabilities.fromCloudRole(String? role) { - final normalized = role ?? 'viewer'; - final canEdit = normalized == 'owner' || normalized == 'editor'; - final isOwner = normalized == 'owner'; - return StrategyCapabilities( - canRenameStrategy: canEdit, - canDeleteStrategy: isOwner, - canDuplicateStrategy: canEdit, - canMoveStrategy: canEdit, - canEditPages: canEdit, - canAddPage: canEdit, - canRenamePage: canEdit, - canDeletePage: canEdit, - canReorderPages: canEdit, - canCreateFolder: isOwner, - canEditFolder: isOwner, - canDeleteFolder: isOwner, - canMoveFolder: isOwner, - ); - } -} +export 'package:icarus/collab/strategy_capabilities.dart'; final currentStrategyCapabilitiesProvider = Provider((ref) { - final strategySource = - ref.watch(strategyProvider.select((value) => value.source)); - if (strategySource != StrategySource.cloud || - !ref.watch(isCloudCollabEnabledProvider)) { + final strategy = ref.watch( + strategyProvider.select((value) => (value.source, value.strategyId)), + ); + if (strategy.$1 != StrategySource.cloud) { return StrategyCapabilities.fullAccess(); } - final role = ref.watch(remoteEditorSnapshotProvider).valueOrNull?.header.role; + final header = ref.watch(remoteEditorSnapshotProvider).valueOrNull?.header; + final role = header?.publicId == strategy.$2 ? header?.role : null; return StrategyCapabilities.fromCloudRole(role); }); diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index 271f6b30..95f6f09d 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -13,6 +13,77 @@ import 'package:icarus/providers/collab/cloud_collab_provider.dart'; import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:uuid/uuid.dart'; +class StrategyOutboxSession { + const StrategyOutboxSession({ + required this.accountId, + required this.isReady, + required this.hasAuthIncident, + }); + + final String? accountId; + final bool isReady; + final bool hasAuthIncident; +} + +final strategyOutboxSessionProvider = Provider((ref) { + final auth = ref.watch(authProvider); + return StrategyOutboxSession( + accountId: auth.user?.id, + isReady: auth.isAuthenticated && auth.isConvexUserReady, + hasAuthIncident: auth.hasActiveAuthIncident, + ); +}); + +class StrategyOutboxSummary { + const StrategyOutboxSummary({ + required this.strategyPublicId, + required this.queuedCount, + required this.inFlightCount, + required this.pausedCount, + required this.attentionCount, + required this.successorCount, + this.reason, + }); + + final String strategyPublicId; + final int queuedCount; + final int inFlightCount; + final int pausedCount; + final int attentionCount; + final int successorCount; + final String? reason; + + int get workCount => + queuedCount + + inFlightCount + + pausedCount + + attentionCount + + successorCount; + bool get hasRunnableWork => queuedCount > 0 || inFlightCount > 0; + bool get needsAttention => pausedCount > 0 || attentionCount > 0; +} + +class AccountStrategyOutboxSummary { + const AccountStrategyOutboxSummary({ + this.accountId, + this.strategies = const {}, + }); + + final String? accountId; + final Map strategies; + + int get workCount => strategies.values.fold( + 0, + (total, strategy) => total + strategy.workCount, + ); + int get strategyCount => strategies.length; + bool get hasWork => workCount > 0; + bool get hasRunnableWork => + strategies.values.any((strategy) => strategy.hasRunnableWork); + bool get needsAttention => + strategies.values.any((strategy) => strategy.needsAttention); +} + class StrategyOpQueueState { const StrategyOpQueueState({ this.accountId, @@ -25,11 +96,13 @@ class StrategyOpQueueState { this.attentionByEntityKey = const {}, this.loadIssues = const [], this.durableLoaded = false, + this.hasDurabilityFailure = false, this.isFlushing = false, this.lastError, this.lastFlushAt, this.lastAcks = const [], this.lastAckBatch = const [], + this.accountOutbox = const AccountStrategyOutboxSummary(), }); final String? accountId; @@ -42,17 +115,23 @@ class StrategyOpQueueState { final Map attentionByEntityKey; final List loadIssues; final bool durableLoaded; + final bool hasDurabilityFailure; final bool isFlushing; final String? lastError; final DateTime? lastFlushAt; final List lastAcks; final List lastAckBatch; + final AccountStrategyOutboxSummary accountOutbox; bool get needsAttention => loadIssues.isNotEmpty || + hasDurabilityFailure || pausedByEntityKey.isNotEmpty || attentionByEntityKey.isNotEmpty; + bool get outboxIsReliable => + durableLoaded && loadIssues.isEmpty && !hasDurabilityFailure; + List get pending => [ ...queuedByEntityKey.values.map((intent) => intent.pending), ...inFlightByEntityKey.values.map((intent) => intent.pending), @@ -67,12 +146,14 @@ class StrategyOpQueueState { Map? successorByEntityKey, Map? pausedByEntityKey, Map? attentionByEntityKey, + bool? hasDurabilityFailure, bool? isFlushing, String? lastError, bool clearError = false, DateTime? lastFlushAt, List? lastAcks, List? lastAckBatch, + AccountStrategyOutboxSummary? accountOutbox, }) { return StrategyOpQueueState( accountId: accountId, @@ -85,11 +166,13 @@ class StrategyOpQueueState { attentionByEntityKey: attentionByEntityKey ?? this.attentionByEntityKey, loadIssues: loadIssues, durableLoaded: durableLoaded, + hasDurabilityFailure: hasDurabilityFailure ?? this.hasDurabilityFailure, isFlushing: isFlushing ?? this.isFlushing, lastError: clearError ? null : (lastError ?? this.lastError), lastFlushAt: lastFlushAt ?? this.lastFlushAt, lastAcks: lastAcks ?? this.lastAcks, lastAckBatch: lastAckBatch ?? this.lastAckBatch, + accountOutbox: accountOutbox ?? this.accountOutbox, ); } } @@ -107,11 +190,21 @@ class StrategyOpQueueNotifier extends Notifier { static const int _maxBatchSize = 40; static const int _maxAttempts = 8; static const Duration _debounceDelay = Duration(milliseconds: 180); + static const Duration _busyBackgroundRetryDelay = Duration(seconds: 1); Timer? _debounceTimer; Timer? _retryTimer; + Timer? _backgroundRetryTimer; int _offlineRetryCount = 0; + bool _networkBusy = false; + ({String accountId, String strategyPublicId})? _drainingStrategy; + bool _isDisposed = false; late DurableStrategyOutboxStore _store; late Map _recordsByStorageKey; + final Set _awaitingRemoteAdoption = {}; + final Set _uncertainOversizedParking = {}; + final Set _uncertainDurableRecords = {}; + final Map _uncertainDurableIntents = {}; + String? _uncertainOversizedParkingMessage; Future _writeTail = Future.value(); ConvexStrategyRepository get _repo => @@ -119,34 +212,79 @@ class StrategyOpQueueNotifier extends Notifier { @override StrategyOpQueueState build() { + _isDisposed = false; _store = ref.read(durableStrategyOutboxStoreProvider); final loaded = _store.load(); _recordsByStorageKey = { for (final record in loaded.records) record.storageKey: record, }; ref.onDispose(() { + _isDisposed = true; _retryTimer?.cancel(); _debounceTimer?.cancel(); + _backgroundRetryTimer?.cancel(); }); + ref.listen(strategyOutboxSessionProvider, + (previous, next) { + if (previous?.accountId != next.accountId) { + setCurrentAccount(next.accountId); + } + final becameReady = !(previous?.isReady ?? false) && next.isReady; + final recovered = + (previous?.hasAuthIncident ?? false) && !next.hasAuthIncident; + if (becameReady || recovered) { + retryCurrentAccount(); + } + }); + ref.listen>(convexConnectionProvider, (previous, next) { + if (previous?.valueOrNull != true && next.valueOrNull == true) { + _scheduleBackgroundDrain(ignoreBackoff: true); + } + }); + final session = ref.read(strategyOutboxSessionProvider); + if (session.accountId != null && + loaded.records.any((record) => record.accountId == session.accountId)) { + _backgroundRetryTimer = Timer( + Duration.zero, + () => retryCurrentAccount(), + ); + } return StrategyOpQueueState( + accountId: session.accountId, clientId: const Uuid().v4(), loadIssues: loaded.issues, durableLoaded: true, + hasDurabilityFailure: loaded.issues.isNotEmpty, lastError: loaded.issues.isEmpty ? null : 'The cloud outbox contains unreadable saved work.', + accountOutbox: _accountSummary(session.accountId), ); } + void setCurrentAccount(String? accountId) { + if (state.accountId == accountId) { + _publishAccountSummary(); + _scheduleBackgroundDrain(ignoreBackoff: true); + return; + } + setActiveStrategy(null, accountId: accountId); + } + void setActiveStrategy( String? strategyPublicId, { required String? accountId, }) { if (state.strategyPublicId == strategyPublicId && - state.accountId == accountId) return; + state.accountId == accountId) { + _publishAccountSummary(); + _scheduleBackgroundDrain(ignoreBackoff: true); + return; + } _debounceTimer?.cancel(); _retryTimer?.cancel(); + _awaitingRemoteAdoption.clear(); _offlineRetryCount = 0; final matching = accountId == null || strategyPublicId == null ? const [] @@ -156,6 +294,7 @@ class StrategyOpQueueNotifier extends Notifier { record.strategyPublicId == strategyPublicId) .toList(growable: false); final queued = {}; + final inFlight = {}; final successors = {}; final paused = {}; final attention = {}; @@ -173,34 +312,84 @@ class StrategyOpQueueNotifier extends Notifier { } switch (record.status) { case DurableOutboxStatus.queued: + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else { + queued[record.entityKey] = intent; + } case DurableOutboxStatus.inFlight: - // An interrupted request is replayed with its original op/client id. - queued[record.entityKey] = intent; + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else if (_drainingStrategy == + (accountId: accountId, strategyPublicId: strategyPublicId)) { + inFlight[record.entityKey] = InFlightEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + sentAt: record.updatedAt, + ); + } else { + // An interrupted request is replayed with its original op/client + // id after restart. + queued[record.entityKey] = intent; + } case DurableOutboxStatus.paused: - paused[record.entityKey] = intent; + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else { + paused[record.entityKey] = intent; + } case DurableOutboxStatus.attention: attention[record.entityKey] = intent; } } + for (final record in _uncertainDurableIntents.values.where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId)) { + attention[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + queued.remove(record.entityKey); + inFlight.remove(record.entityKey); + paused.remove(record.entityKey); + final successor = record.successorPending; + if (successor != null) { + successors[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: successor, + ); + } + } final clientId = matching.firstOrNull?.pending.clientId ?? const Uuid().v4(); + final hasDurabilityFailure = state.loadIssues.isNotEmpty || + _hasDurabilityFailureForAccount(accountId); state = StrategyOpQueueState( accountId: accountId, strategyPublicId: strategyPublicId, clientId: clientId, queuedByEntityKey: queued, + inFlightByEntityKey: inFlight, successorByEntityKey: successors, pausedByEntityKey: paused, attentionByEntityKey: attention, loadIssues: state.loadIssues, durableLoaded: true, - lastError: _loadedAttentionMessage( - loadIssues: state.loadIssues, - paused: paused, - attention: attention, - ), + hasDurabilityFailure: hasDurabilityFailure, + lastError: hasDurabilityFailure + ? (_uncertainOversizedParkingMessage ?? + 'Cloud work could not be verified in the durable outbox.') + : _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: paused, + attention: attention, + ), + accountOutbox: _accountSummary(accountId), ); - if (queued.isNotEmpty) _scheduleFlush(flushImmediately: true); + if (queued.isNotEmpty && inFlight.isEmpty) { + _scheduleFlush(flushImmediately: true); + } + _scheduleBackgroundDrain(ignoreBackoff: true); } Future enqueue( @@ -303,6 +492,7 @@ class StrategyOpQueueNotifier extends Notifier { state = state.copyWith( lastError: 'Cloud work could not be queued without an active account.', + hasDurabilityFailure: true, ); } return; @@ -323,6 +513,7 @@ class StrategyOpQueueNotifier extends Notifier { var changed = false; try { for (final key in keys) { + if (_awaitingRemoteAdoption.contains(key)) continue; final desired = desiredOps[key]; final existing = queued[key]; final inFlightIntent = state.inFlightByEntityKey[key]; @@ -331,11 +522,96 @@ class StrategyOpQueueNotifier extends Notifier { final pausedIntent = paused[key]; final attentionIntent = attention[key]; + // A rejected op remains the durable authority until the user + // explicitly retries it. Reconciliation may update its successor, but + // it must never make rejected work eligible for an automatic flush. + if (attentionIntent != null) { + if (desired == null) continue; + final storageKey = DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: key, + ); + final current = + _recordForActiveKey(key) ?? _uncertainDurableIntents[storageKey]; + if (current == null) { + throw StateError('Durable attention record is missing for $key.'); + } + if (_sameIntent(attentionIntent.pending.op, desired)) { + final hasUncertainDurableRecord = + _uncertainDurableRecords.contains(storageKey); + if (successorIntent != null || hasUncertainDurableRecord) { + final recoveredOversizedParking = + _uncertainOversizedParking.contains(storageKey); + final isOversized = + cloudOperationExceedsPolicy(current.pending.op); + await _putRecord(current.copyWith( + status: isOversized + ? DurableOutboxStatus.attention + : (hasUncertainDurableRecord + ? DurableOutboxStatus.queued + : current.status), + clearSuccessorPending: true, + updatedAt: DateTime.now(), + lastError: isOversized ? cloudOperationTooLargeMessage : null, + clearError: !isOversized, + )); + if (recoveredOversizedParking) { + _uncertainOversizedParking.remove(storageKey); + if (_uncertainOversizedParking.isEmpty) { + _uncertainOversizedParkingMessage = null; + } + } + successors.remove(key); + if (hasUncertainDurableRecord && !isOversized) { + attention.remove(key); + queued[key] = attentionIntent; + } + changed = true; + } + continue; + } + if (successorIntent != null && + _sameIntent(successorIntent.pending.op, desired)) { + continue; + } + final pending = PendingOp( + op: successorIntent == null + ? desired + : _mergeQueuedIntent(successorIntent.pending.op, desired) ?? + desired, + clientId: successorIntent?.pending.clientId ?? + attentionIntent.pending.clientId, + ); + final recoveredOversizedParking = + _uncertainOversizedParking.contains(current.storageKey); + await _putRecord(current.copyWith( + status: DurableOutboxStatus.attention, + successorPending: pending, + updatedAt: DateTime.now(), + lastError: recoveredOversizedParking + ? cloudOperationTooLargeMessage + : null, + )); + if (recoveredOversizedParking) { + _uncertainOversizedParking.remove(current.storageKey); + if (_uncertainOversizedParking.isEmpty) { + _uncertainOversizedParkingMessage = null; + } + } + successors[key] = QueuedEntityIntent( + entityKey: key, + pending: pending, + ); + changed = true; + continue; + } + if (desired == null) { if (inFlight != null || successorIntent != null) { continue; } - final current = existing ?? pausedIntent ?? attentionIntent; + final current = existing ?? pausedIntent; if (current != null) { await _removeRecordIfCurrent(key, current.pending.op.opId); queued.remove(key); @@ -360,8 +636,7 @@ class StrategyOpQueueNotifier extends Notifier { continue; } - if (inFlightIntent != null && - key.kind == EntitySyncKeyKind.pageDescriptor) { + if (inFlightIntent != null) { if (successorIntent != null && _sameIntent(successorIntent.pending.op, desired)) { continue; @@ -369,7 +644,8 @@ class StrategyOpQueueNotifier extends Notifier { final pending = PendingOp( op: successorIntent == null ? desired - : _mergeQueuedIntent(successorIntent.pending.op, desired)!, + : _mergeQueuedIntent(successorIntent.pending.op, desired) ?? + desired, clientId: successorIntent?.pending.clientId ?? state.clientId!, ); await _putRecord(_recordFor( @@ -387,9 +663,7 @@ class StrategyOpQueueNotifier extends Notifier { continue; } - if (existing != null && - successorIntent != null && - key.kind == EntitySyncKeyKind.pageDescriptor) { + if (existing != null && successorIntent != null) { if (_sameIntent(existing.pending.op, desired)) { await _putRecord(_recordFor( key: key, @@ -405,7 +679,8 @@ class StrategyOpQueueNotifier extends Notifier { continue; } final pending = PendingOp( - op: _mergeQueuedIntent(successorIntent.pending.op, desired)!, + op: _mergeQueuedIntent(successorIntent.pending.op, desired) ?? + desired, clientId: successorIntent.pending.clientId, ); await _putRecord(_recordFor( @@ -426,14 +701,10 @@ class StrategyOpQueueNotifier extends Notifier { continue; } - // A rejected opId is an immutable server event. Reconciliation must - // replace it with the newly based op instead of replaying the reject. - final base = attentionIntent ?? pausedIntent ?? existing; - final merged = attentionIntent != null + final base = pausedIntent ?? existing; + final merged = base == null ? desired - : (base == null - ? desired - : _mergeQueuedIntent(base.pending.op, desired)); + : _mergeQueuedIntent(base.pending.op, desired); if (merged == null) { if (base != null) { await _removeRecordIfCurrent(key, base.pending.op.opId); @@ -448,19 +719,29 @@ class StrategyOpQueueNotifier extends Notifier { final pending = PendingOp( op: merged, clientId: base?.pending.clientId ?? state.clientId!, - attempts: attentionIntent != null ? 0 : (base?.pending.attempts ?? 0), - lastAttemptAt: - attentionIntent != null ? null : base?.pending.lastAttemptAt, + attempts: base?.pending.attempts ?? 0, + lastAttemptAt: base?.pending.lastAttemptAt, ); final record = _recordFor( key: key, pending: pending, - status: DurableOutboxStatus.queued, + status: cloudOperationExceedsPolicy(pending.op) + ? DurableOutboxStatus.attention + : DurableOutboxStatus.queued, + lastError: cloudOperationExceedsPolicy(pending.op) + ? cloudOperationTooLargeMessage + : null, ); await _putRecord(record); - queued[key] = QueuedEntityIntent(entityKey: key, pending: pending); + final intent = QueuedEntityIntent(entityKey: key, pending: pending); + if (record.status == DurableOutboxStatus.attention) { + attention[key] = intent; + queued.remove(key); + } else { + queued[key] = intent; + attention.remove(key); + } paused.remove(key); - attention.remove(key); changed = true; } } catch (error, stackTrace) { @@ -478,6 +759,7 @@ class StrategyOpQueueNotifier extends Notifier { pausedByEntityKey: paused, attentionByEntityKey: attention, successorByEntityKey: successors, + hasDurabilityFailure: _hasDurabilityFailureForCurrentAccount, lastError: attentionMessage, clearError: attentionMessage == null, ); @@ -498,19 +780,32 @@ class StrategyOpQueueNotifier extends Notifier { final queued = Map.from( state.queuedByEntityKey, ); + final attention = Map.from( + state.attentionByEntityKey, + ); try { for (final entry in state.pausedByEntityKey.entries) { final pending = PendingOp( op: entry.value.pending.op, clientId: entry.value.pending.clientId, ); + final isOversized = cloudOperationExceedsPolicy(pending.op); await _putRecord(_recordFor( key: entry.key, pending: pending, - status: DurableOutboxStatus.queued, + status: isOversized + ? DurableOutboxStatus.attention + : DurableOutboxStatus.queued, + lastError: isOversized ? cloudOperationTooLargeMessage : null, )); - queued[entry.key] = + final intent = QueuedEntityIntent(entityKey: entry.key, pending: pending); + if (isOversized) { + attention[entry.key] = intent; + queued.remove(entry.key); + } else { + queued[entry.key] = intent; + } } } catch (error, stackTrace) { _recordPersistenceFailure(error, stackTrace); @@ -519,8 +814,9 @@ class StrategyOpQueueNotifier extends Notifier { state = state.copyWith( queuedByEntityKey: queued, pausedByEntityKey: const {}, - clearError: - state.attentionByEntityKey.isEmpty && state.loadIssues.isEmpty, + attentionByEntityKey: attention, + hasDurabilityFailure: _hasDurabilityFailureForCurrentAccount, + clearError: attention.isEmpty && state.loadIssues.isEmpty, ); _scheduleFlush(flushImmediately: flushImmediately); }); @@ -551,33 +847,55 @@ class StrategyOpQueueNotifier extends Notifier { final rejectedOp = rejected.op; final successor = record?.successorPending; final retryOp = successor?.op ?? rejectedOp; + final isPayloadPolicyAttention = + record?.lastError == cloudOperationTooLargeMessage || + cloudOperationExceedsPolicy(rejectedOp); final retryRevision = record?.latestServerRevision ?? rejectedOp.expectedRevision; - if (retryRevision == null) continue; + if (!isPayloadPolicyAttention && retryRevision == null) continue; final isTombstoneRestore = (retryOp is ElementAddOp || retryOp is LineupAddOp) && (record?.lastError == 'missing_expected_revision' || record?.lastError == 'revision_mismatch'); - final rebasedOp = _rebaseRejectedOp( - retryOp, - retryRevision, - preserveAdd: isTombstoneRestore, - ); + final rebasedOp = isPayloadPolicyAttention + ? retryOp.withOpId(const Uuid().v4()) + : _rebaseRejectedOp( + retryOp, + retryRevision!, + preserveAdd: isTombstoneRestore, + ); final pending = PendingOp( op: rebasedOp, clientId: successor?.clientId ?? rejected.clientId, ); + final isRetryOversized = cloudOperationExceedsPolicy(pending.op); await _putRecord(_recordFor( key: entry.key, pending: pending, - status: DurableOutboxStatus.queued, + status: isRetryOversized + ? DurableOutboxStatus.attention + : DurableOutboxStatus.queued, clearSuccessorPending: true, + lastError: isRetryOversized ? cloudOperationTooLargeMessage : null, )); - queued[entry.key] = QueuedEntityIntent( + _uncertainOversizedParking.remove( + DurableOutboxRecord.createStorageKey( + accountId: state.accountId!, + strategyPublicId: state.strategyPublicId!, + entityKey: entry.key, + ), + ); + final intent = QueuedEntityIntent( entityKey: entry.key, pending: pending, ); - attention.remove(entry.key); + if (isRetryOversized) { + attention[entry.key] = intent; + queued.remove(entry.key); + } else { + queued[entry.key] = intent; + attention.remove(entry.key); + } successors.remove(entry.key); changed = true; } @@ -592,6 +910,9 @@ class StrategyOpQueueNotifier extends Notifier { ); return; } + if (_uncertainOversizedParking.isEmpty) { + _uncertainOversizedParkingMessage = null; + } final attentionMessage = _loadedAttentionMessage( loadIssues: state.loadIssues, paused: state.pausedByEntityKey, @@ -601,6 +922,7 @@ class StrategyOpQueueNotifier extends Notifier { queuedByEntityKey: queued, attentionByEntityKey: attention, successorByEntityKey: successors, + hasDurabilityFailure: _hasDurabilityFailureForCurrentAccount, lastError: attentionMessage, clearError: attentionMessage == null, ); @@ -608,25 +930,217 @@ class StrategyOpQueueNotifier extends Notifier { }); } + /// Discards selected server-rejected intents after an explicit user choice. + /// + /// Each durable record contains both the rejected predecessor and any newer + /// successor for that entity. Removing the record discards both, without + /// changing unrelated queued, in-flight, paused, or rejected work. + Future> discardRejected( + Set entityKeys, + ) { + return _serializeWrite(() async { + final attention = Map.from( + state.attentionByEntityKey, + ); + final successors = Map.from( + state.successorByEntityKey, + ); + final discarded = {}; + Object? persistenceError; + StackTrace? persistenceStackTrace; + + for (final key in entityKeys) { + final rejected = attention[key]; + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + if (rejected == null || accountId == null || strategyPublicId == null) { + continue; + } + final storageKey = DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: key, + ); + final hasUncertainParking = + _uncertainOversizedParking.contains(storageKey); + final hasUncertainDurableRecord = + _uncertainDurableRecords.contains(storageKey); + final record = _recordForActiveKey(key); + final hasMatchingAttentionRecord = record != null && + (record.status == DurableOutboxStatus.attention || + cloudOperationExceedsPolicy(record.pending.op)) && + record.pending.op.opId == rejected.pending.op.opId; + if (!hasUncertainParking && + !hasUncertainDurableRecord && + !hasMatchingAttentionRecord) { + continue; + } + try { + await _removeRecordByStorageKey(storageKey); + _uncertainOversizedParking.remove(storageKey); + attention.remove(key); + successors.remove(key); + _awaitingRemoteAdoption.add(key); + discarded.add(key); + } catch (error, stackTrace) { + persistenceError = error; + persistenceStackTrace = stackTrace; + break; + } + } + + if (persistenceError != null) { + log( + 'Durable outbox persistence failed: $persistenceError', + name: 'strategy_outbox', + error: persistenceError, + stackTrace: persistenceStackTrace, + ); + } + if (_uncertainOversizedParking.isEmpty) { + _uncertainOversizedParkingMessage = null; + } + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: state.pausedByEntityKey, + attention: attention, + ); + final errorMessage = persistenceError == null + ? attentionMessage + : 'Cloud work could not be removed from the durable outbox: ' + '$persistenceError'; + state = state.copyWith( + attentionByEntityKey: attention, + successorByEntityKey: successors, + hasDurabilityFailure: _hasDurabilityFailureForCurrentAccount, + lastError: errorMessage, + clearError: errorMessage == null, + ); + return Set.unmodifiable(discarded); + }); + } + + void completeRemoteAdoption(Set entityKeys) { + _awaitingRemoteAdoption.removeAll(entityKeys); + } + + Future _parkOversizedQueuedOps() async { + if (_hasUncertainParkingForActiveStrategy) return false; + final oversized = state.queuedByEntityKey.entries + .where((entry) => cloudOperationExceedsPolicy(entry.value.pending.op)) + .toList(growable: false); + if (oversized.isEmpty) return true; + + final queued = Map.from( + state.queuedByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); + Object? persistenceError; + StackTrace? persistenceStackTrace; + for (final entry in oversized) { + final record = _recordForActiveKey(entry.key); + if (record == null || + record.pending.op.opId != entry.value.pending.op.opId) { + persistenceError ??= + StateError('Durable queued record is missing for ${entry.key}.'); + queued.remove(entry.key); + attention[entry.key] = entry.value; + _uncertainOversizedParking.add( + DurableOutboxRecord.createStorageKey( + accountId: state.accountId!, + strategyPublicId: state.strategyPublicId!, + entityKey: entry.key, + ), + ); + continue; + } + // Keep an already-durable queued record untouched. The in-memory view + // and restart loader both classify it as attention, so parking never + // risks destroying the only recoverable copy with an overwrite. + queued.remove(entry.key); + attention[entry.key] = entry.value; + } + if (persistenceError != null) { + log( + 'Durable outbox persistence failed: $persistenceError', + name: 'strategy_outbox', + error: persistenceError, + stackTrace: persistenceStackTrace, + ); + _debounceTimer?.cancel(); + _debounceTimer = null; + _retryTimer?.cancel(); + _retryTimer = null; + _uncertainOversizedParkingMessage = + 'Cloud work could not be verified in the durable outbox. ' + 'Nothing was sent. The change still needs attention: ' + '$persistenceError'; + } + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: state.pausedByEntityKey, + attention: attention, + ); + state = state.copyWith( + queuedByEntityKey: queued, + attentionByEntityKey: attention, + hasDurabilityFailure: _hasDurabilityFailureForCurrentAccount, + lastError: persistenceError == null + ? attentionMessage + : _uncertainOversizedParkingMessage, + clearError: persistenceError == null && attentionMessage == null, + ); + return persistenceError == null; + } + Future flushNow() async { await _writeTail; - if (state.isFlushing) return; + if (_isDisposed || _networkBusy) return; + final accountId = state.accountId; final strategyPublicId = state.strategyPublicId; - if (strategyPublicId == null || state.queuedByEntityKey.isEmpty) return; + if (accountId == null || + strategyPublicId == null || + state.queuedByEntityKey.isEmpty) { + return; + } + + if (!await _parkOversizedQueuedOps() || _isDisposed) return; + if (state.queuedByEntityKey.isEmpty) return; + + await _flushStrategy( + accountId: accountId, + strategyPublicId: strategyPublicId, + isBackground: false, + ); + } + + Future _flushStrategy({ + required String accountId, + required String strategyPublicId, + required bool isBackground, + bool ignoreBackoff = false, + }) async { + if (_isDisposed || _networkBusy) return; final mode = ref.read(cloudCollabModeProvider); if (!mode.featureFlagEnabled || mode.forceLocalFallback) return; final auth = ref.read(authProvider); if (auth.hasActiveAuthIncident) { - state = state.copyWith( - lastError: 'Cloud auth incident active. Saved work is paused.', - ); + if (!isBackground && _isActive(accountId, strategyPublicId)) { + state = state.copyWith( + lastError: 'Cloud auth incident active. Saved work is paused.', + ); + } return; } - if (auth.user?.id != state.accountId) { - state = state.copyWith( - lastError: 'Cloud outbox belongs to a different account.', - ); + if (auth.user?.id != accountId) { + if (!isBackground && _isActive(accountId, strategyPublicId)) { + state = state.copyWith( + lastError: 'Cloud outbox belongs to a different account.', + ); + } return; } if (!auth.isAuthenticated || @@ -637,51 +1151,48 @@ class StrategyOpQueueNotifier extends Notifier { : (!auth.isConvexUserReady ? 'Cloud user setup is not ready.' : 'Cloud connection is offline.'); - _scheduleRetry( - state.queuedByEntityKey.values.map((item) => item.pending).toList(), - delay: _offlineRetryDelay(), - ); - state = state.copyWith(lastError: message); + if (!isBackground && _isActive(accountId, strategyPublicId)) { + _scheduleRetry( + state.queuedByEntityKey.values.map((item) => item.pending).toList(), + delay: _offlineRetryDelay(), + ); + state = state.copyWith(lastError: message); + } return; } - final candidates = state.queuedByEntityKey.values.toList(growable: false); - if (candidates.isEmpty) return; - final batchClientId = candidates.first.pending.clientId; - final batch = candidates - .where((intent) => intent.pending.clientId == batchClientId) - .take(_maxBatchSize) - .toList(growable: false); - final queued = Map.from( - state.queuedByEntityKey, - ); - final inFlight = Map.from( - state.inFlightByEntityKey, + _networkBusy = true; + _drainingStrategy = ( + accountId: accountId, + strategyPublicId: strategyPublicId, ); - final sentAt = DateTime.now(); + List batch; + var batchSucceeded = false; try { - for (final intent in batch) { - await _putRecord(_recordFor( - key: intent.entityKey, - pending: intent.pending, - status: DurableOutboxStatus.inFlight, - )); - queued.remove(intent.entityKey); - inFlight[intent.entityKey] = InFlightEntityIntent( - entityKey: intent.entityKey, - pending: intent.pending, - sentAt: sentAt, - ); - } + batch = await _claimBatch( + accountId: accountId, + strategyPublicId: strategyPublicId, + ignoreBackoff: ignoreBackoff || !isBackground, + ); } catch (error, stackTrace) { _recordPersistenceFailure(error, stackTrace); + _finishNetworkLane(); return; } - state = state.copyWith( - queuedByEntityKey: queued, - inFlightByEntityKey: inFlight, - isFlushing: true, - clearError: true, + if (batch.isEmpty) { + _finishNetworkLane(); + _scheduleBackgroundDrain(); + return; + } + if (_isDisposed) { + _finishNetworkLane(); + return; + } + final batchClientId = batch.first.pending.clientId; + final isActiveStrategy = _isActive(accountId, strategyPublicId); + _refreshActiveQueueView( + isFlushing: isActiveStrategy, + clearError: isActiveStrategy, ); try { @@ -691,10 +1202,10 @@ class StrategyOpQueueNotifier extends Notifier { final acks = await _repo.applyBatch( strategyPublicId: strategyPublicId, clientId: batchClientId, - ops: batch.map((intent) => intent.pending.op).toList(growable: false), + ops: batch.map((record) => record.pending.op).toList(growable: false), ); - await _applyAcks(batch, acks); - if (state.queuedByEntityKey.isNotEmpty) unawaited(flushNow()); + await _applyAcksForRecords(batch, acks); + batchSucceeded = true; } catch (error, stackTrace) { if (isConvexUnauthenticatedError(error)) { unawaited(ref.read(authProvider.notifier).reportConvexUnauthenticated( @@ -706,86 +1217,141 @@ class StrategyOpQueueNotifier extends Notifier { log('Failed flushing op queue: $error', error: error, stackTrace: stackTrace); } - await _restoreBatchAfterFailure(batch, lastError: '$error'); + await _restoreRecordsAfterFailure(batch, lastError: '$error'); + } finally { + _finishNetworkLane(); + } + + if (!_isDisposed) { + if (state.queuedByEntityKey.isNotEmpty && + (isBackground || batchSucceeded)) { + unawaited(flushNow()); + } + _scheduleBackgroundDrain(); } } - Future _applyAcks( - List batch, + Future> _claimBatch({ + required String accountId, + required String strategyPublicId, + required bool ignoreBackoff, + }) { + return _serializeWrite(() async { + final now = DateTime.now(); + final candidates = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId && + (record.status == DurableOutboxStatus.queued || + record.status == DurableOutboxStatus.inFlight) && + !_uncertainDurableRecords.contains(record.storageKey) && + !cloudOperationExceedsPolicy(record.pending.op) && + (ignoreBackoff || !_nextAttemptAt(record).isAfter(now))) + .toList(growable: false); + if (candidates.isEmpty) return const []; + final batchClientId = candidates.first.pending.clientId; + final selected = []; + for (final candidate in candidates) { + if (candidate.pending.clientId != batchClientId) continue; + if (selected.length >= _maxBatchSize) break; + final nextSelection = [...selected, candidate]; + final byteSize = serializedCloudBatchUtf8Bytes( + strategyPublicId: strategyPublicId, + clientId: batchClientId, + ops: nextSelection.map((record) => record.pending.op), + ); + if (byteSize > maxCloudBatchBytes) break; + selected.add(candidate); + } + final claimed = []; + for (final record in selected) { + final current = _recordsByStorageKey[record.storageKey]; + if (current == null || + current.pending.op.opId != record.pending.op.opId || + (current.status != DurableOutboxStatus.queued && + current.status != DurableOutboxStatus.inFlight)) { + continue; + } + final inFlight = current.copyWith( + status: DurableOutboxStatus.inFlight, + updatedAt: now, + clearError: true, + ); + await _putRecord(inFlight); + claimed.add(inFlight); + } + return claimed; + }); + } + + Future _applyAcksForRecords( + List batch, + List acks, + ) { + return _serializeWrite(() => _applyAcksForRecordsLocked(batch, acks)); + } + + Future _applyAcksForRecordsLocked( + List batch, List acks, ) async { final byOpId = {for (final item in batch) item.pending.op.opId: item}; final ackByOpId = {for (final ack in acks) ack.opId: ack}; - if (ackByOpId.length != batch.length) { + if (ackByOpId.length != batch.length || + !ackByOpId.keys.toSet().containsAll(byOpId.keys)) { throw StateError( 'Server returned an incomplete operation result batch.', ); } - final inFlight = Map.from( - state.inFlightByEntityKey, - ); - final queued = Map.from( - state.queuedByEntityKey, - ); - final successors = Map.from( - state.successorByEntityKey, - ); - final attention = Map.from( - state.attentionByEntityKey, - ); final acked = []; for (final ack in acks) { final sent = byOpId[ack.opId]; if (sent == null) continue; - inFlight.remove(sent.entityKey); acked.add(AckedEntityIntent( entityKey: sent.entityKey, op: sent.pending.op, ack: ack, )); - final current = _recordForActiveKey(sent.entityKey); + final current = _recordsByStorageKey[sent.storageKey]; if (current?.pending.op.opId != ack.opId) continue; final successor = current!.successorPending; - final successorRevision = ack.appliedRevision ?? ack.latestRevision; - if (successor != null && successorRevision != null) { + // Only an accepted predecessor establishes a revision for automatic + // promotion. A rejected predecessor leaves both intents in attention. + final successorRevision = ack.appliedRevision; + if (successor != null && ack.isAck && successorRevision != null) { + final predecessorCreatedEntity = + sent.pending.op is ElementAddOp || sent.pending.op is LineupAddOp; final promoted = PendingOp( op: _rebaseRejectedOp( successor.op, successorRevision, - preserveAdd: true, + preserveAdd: !predecessorCreatedEntity, ), clientId: successor.clientId, ); + final isPromotedOversized = cloudOperationExceedsPolicy(promoted.op); await _putRecord(current.copyWith( pending: promoted, - status: DurableOutboxStatus.queued, + status: isPromotedOversized + ? DurableOutboxStatus.attention + : DurableOutboxStatus.queued, updatedAt: DateTime.now(), clearSuccessorPending: true, - clearError: true, + lastError: isPromotedOversized ? cloudOperationTooLargeMessage : null, + clearError: !isPromotedOversized, clearLatestServerRevision: true, )); - queued[sent.entityKey] = QueuedEntityIntent( - entityKey: sent.entityKey, - pending: promoted, - ); - successors.remove(sent.entityKey); - attention.remove(sent.entityKey); } else if (successor != null) { final retained = current.copyWith( status: DurableOutboxStatus.attention, updatedAt: DateTime.now(), lastError: ack.reason ?? - 'The final Page change is waiting for a server revision.', + 'The final change is waiting for conflict resolution.', latestServerRevision: ack.latestRevision, ); await _putRecord(retained); - attention[sent.entityKey] = QueuedEntityIntent( - entityKey: sent.entityKey, - pending: sent.pending, - ); } else if (ack.isAck) { - await _removeRecordIfCurrent(sent.entityKey, ack.opId); - successors.remove(sent.entityKey); + await _removeRecordByStorageKeyIfCurrent(sent.storageKey, ack.opId); } else { final rejected = current.copyWith( status: DurableOutboxStatus.attention, @@ -794,84 +1360,72 @@ class StrategyOpQueueNotifier extends Notifier { latestServerRevision: ack.latestRevision, ); await _putRecord(rejected); - attention[sent.entityKey] = QueuedEntityIntent( - entityKey: sent.entityKey, - pending: sent.pending, - ); } } - final attentionMessage = _loadedAttentionMessage( - loadIssues: state.loadIssues, - paused: state.pausedByEntityKey, - attention: attention, - ); - state = state.copyWith( - queuedByEntityKey: queued, - inFlightByEntityKey: inFlight, - successorByEntityKey: successors, - attentionByEntityKey: attention, - isFlushing: false, - lastFlushAt: DateTime.now(), - lastAcks: acks, - lastAckBatch: acked, - lastError: attentionMessage, - clearError: attentionMessage == null, + if (_isDisposed) return; + final first = batch.first; + if (_isActive(first.accountId, first.strategyPublicId)) { + _refreshActiveQueueView( + isFlushing: false, + lastAcks: acks, + lastAckBatch: acked, + lastFlushAt: DateTime.now(), + ); + } else { + _refreshActiveQueueView(); + } + } + + Future _restoreRecordsAfterFailure( + List batch, { + required String lastError, + }) { + return _serializeWrite( + () => _restoreRecordsAfterFailureLocked(batch, lastError: lastError), ); } - Future _restoreBatchAfterFailure( - List batch, { + Future _restoreRecordsAfterFailureLocked( + List batch, { required String lastError, }) async { - final queued = Map.from( - state.queuedByEntityKey, - ); - final inFlight = Map.from( - state.inFlightByEntityKey, - ); - final paused = Map.from( - state.pausedByEntityKey, - ); final retrying = []; try { for (final sent in batch) { - inFlight.remove(sent.entityKey); - if (queued.containsKey(sent.entityKey)) continue; - final pending = sent.pending.incrementAttempt(); + final current = _recordsByStorageKey[sent.storageKey]; + if (current == null || + current.pending.op.opId != sent.pending.op.opId) { + continue; + } + final pending = current.pending.incrementAttempt(); final isPaused = pending.attempts >= _maxAttempts; - await _putRecord(_recordFor( - key: sent.entityKey, + await _putRecord(current.copyWith( pending: pending, status: isPaused ? DurableOutboxStatus.paused : DurableOutboxStatus.queued, + updatedAt: DateTime.now(), lastError: lastError, )); - final intent = QueuedEntityIntent( - entityKey: sent.entityKey, - pending: pending, - ); - if (isPaused) { - paused[sent.entityKey] = intent; - } else { - queued[sent.entityKey] = intent; - retrying.add(pending); - } + if (!isPaused) retrying.add(pending); } } catch (error, stackTrace) { _recordPersistenceFailure(error, stackTrace); return; } - state = state.copyWith( - queuedByEntityKey: queued, - inFlightByEntityKey: inFlight, - pausedByEntityKey: paused, - isFlushing: false, - lastError: paused.isEmpty - ? lastError - : '$lastError (retry paused after $_maxAttempts attempts)', - ); - _scheduleRetry(retrying); + if (_isDisposed) return; + final draining = _drainingStrategy; + if (draining != null && + _isActive(draining.accountId, draining.strategyPublicId)) { + _refreshActiveQueueView( + isFlushing: false, + lastError: lastError, + useProvidedError: true, + ); + _scheduleRetry(retrying); + } else { + _refreshActiveQueueView(); + } } DurableOutboxRecord _recordFor({ @@ -911,8 +1465,38 @@ class StrategyOpQueueNotifier extends Notifier { } Future _putRecord(DurableOutboxRecord record) async { - await _store.put(record); - _recordsByStorageKey[record.storageKey] = record; + try { + await _store.put(record); + _recordsByStorageKey[record.storageKey] = record; + _uncertainDurableRecords.remove(record.storageKey); + _uncertainDurableIntents.remove(record.storageKey); + } catch (_) { + _uncertainDurableRecords.add(record.storageKey); + _uncertainDurableIntents[record.storageKey] = record; + if (cloudOperationExceedsPolicy(record.pending.op)) { + _uncertainOversizedParking.add(record.storageKey); + _uncertainOversizedParkingMessage = + 'Cloud work could not be verified in the durable outbox. ' + 'Nothing was sent.'; + } + _publishAccountSummary(); + rethrow; + } + _publishAccountSummary(); + } + + Future _removeRecordByStorageKey(String storageKey) async { + try { + await _store.remove(storageKey); + _recordsByStorageKey.remove(storageKey); + _uncertainDurableRecords.remove(storageKey); + _uncertainDurableIntents.remove(storageKey); + } catch (_) { + _uncertainDurableRecords.add(storageKey); + _publishAccountSummary(); + rethrow; + } + _publishAccountSummary(); } Future _removeRecordIfCurrent( @@ -921,29 +1505,366 @@ class StrategyOpQueueNotifier extends Notifier { ) async { final record = _recordForActiveKey(key); if (record == null || record.pending.op.opId != opId) return; - await _store.remove(record.storageKey); - _recordsByStorageKey.remove(record.storageKey); + await _removeRecordByStorageKey(record.storageKey); + } + + Future _removeRecordByStorageKeyIfCurrent( + String storageKey, + String opId, + ) async { + final record = _recordsByStorageKey[storageKey]; + if (record == null || record.pending.op.opId != opId) return; + await _removeRecordByStorageKey(storageKey); + } + + bool _isActive(String accountId, String strategyPublicId) { + return state.accountId == accountId && + state.strategyPublicId == strategyPublicId; + } + + void _refreshActiveQueueView({ + bool? isFlushing, + String? lastError, + bool useProvidedError = false, + DateTime? lastFlushAt, + List? lastAcks, + List? lastAckBatch, + bool clearError = false, + }) { + if (_isDisposed) return; + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + final queued = {}; + final inFlight = {}; + final successors = {}; + final paused = {}; + final attention = {}; + if (accountId != null && strategyPublicId != null) { + final isActivelyDraining = _drainingStrategy == + (accountId: accountId, strategyPublicId: strategyPublicId); + for (final record in _recordsByStorageKey.values) { + if (record.accountId != accountId || + record.strategyPublicId != strategyPublicId) { + continue; + } + final intent = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + final successor = record.successorPending; + if (successor != null) { + successors[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: successor, + ); + } + switch (record.status) { + case DurableOutboxStatus.queued: + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else { + queued[record.entityKey] = intent; + } + case DurableOutboxStatus.inFlight: + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else if (isActivelyDraining) { + inFlight[record.entityKey] = InFlightEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + sentAt: record.updatedAt, + ); + } else { + queued[record.entityKey] = intent; + } + case DurableOutboxStatus.paused: + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else { + paused[record.entityKey] = intent; + } + case DurableOutboxStatus.attention: + attention[record.entityKey] = intent; + } + } + for (final record in _uncertainDurableIntents.values.where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId)) { + attention[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + queued.remove(record.entityKey); + inFlight.remove(record.entityKey); + paused.remove(record.entityKey); + final successor = record.successorPending; + if (successor != null) { + successors[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: successor, + ); + } + } + } + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: paused, + attention: attention, + ); + final effectiveError = attentionMessage ?? + (useProvidedError ? lastError : (clearError ? null : state.lastError)); + state = state.copyWith( + queuedByEntityKey: queued, + inFlightByEntityKey: inFlight, + successorByEntityKey: successors, + pausedByEntityKey: paused, + attentionByEntityKey: attention, + accountOutbox: _accountSummary(accountId), + hasDurabilityFailure: state.loadIssues.isNotEmpty || + _hasDurabilityFailureForAccount(accountId), + isFlushing: isFlushing, + lastError: effectiveError, + clearError: effectiveError == null, + lastFlushAt: lastFlushAt, + lastAcks: lastAcks, + lastAckBatch: lastAckBatch, + ); + } + + void _finishNetworkLane() { + _networkBusy = false; + _drainingStrategy = null; + if (!_isDisposed && state.isFlushing) { + _refreshActiveQueueView(isFlushing: false); + } + } + + AccountStrategyOutboxSummary _accountSummary(String? accountId) { + if (accountId == null) return const AccountStrategyOutboxSummary(); + final recordsByStorageKey = {}; + for (final record in _recordsByStorageKey.values) { + if (record.accountId != accountId) continue; + recordsByStorageKey[record.storageKey] = record; + } + for (final record in _uncertainDurableIntents.values) { + if (record.accountId != accountId) continue; + recordsByStorageKey[record.storageKey] = record; + } + + final storageKeysByStrategy = >{}; + for (final entry in recordsByStorageKey.entries) { + (storageKeysByStrategy[entry.value.strategyPublicId] ??= {}) + .add(entry.key); + } + for (final storageKey in { + ..._uncertainDurableRecords, + ..._uncertainOversizedParking, + }) { + final strategyPublicId = _strategyIdForStorageKey( + storageKey, + accountId: accountId, + ); + if (strategyPublicId != null) { + (storageKeysByStrategy[strategyPublicId] ??= {}) + .add(storageKey); + } + } + + final summaries = {}; + for (final entry in storageKeysByStrategy.entries) { + var queuedCount = 0; + var inFlightCount = 0; + var pausedCount = 0; + var attentionCount = 0; + var successorCount = 0; + String? reason; + for (final storageKey in entry.value) { + final record = recordsByStorageKey[storageKey]; + final uncertain = _uncertainDurableRecords.contains(storageKey) || + _uncertainOversizedParking.contains(storageKey); + if (record == null) { + attentionCount += 1; + reason ??= 'Cloud work could not be verified in the durable outbox.'; + continue; + } + final oversized = cloudOperationExceedsPolicy(record.pending.op); + if (uncertain || oversized) { + attentionCount += 1; + reason ??= uncertain + ? 'Cloud work could not be verified in the durable outbox.' + : cloudOperationTooLargeMessage; + } else { + switch (record.status) { + case DurableOutboxStatus.queued: + queuedCount += 1; + case DurableOutboxStatus.inFlight: + inFlightCount += 1; + case DurableOutboxStatus.paused: + pausedCount += 1; + case DurableOutboxStatus.attention: + attentionCount += 1; + } + reason ??= record.lastError; + } + if (record.successorPending != null) successorCount += 1; + } + summaries[entry.key] = StrategyOutboxSummary( + strategyPublicId: entry.key, + queuedCount: queuedCount, + inFlightCount: inFlightCount, + pausedCount: pausedCount, + attentionCount: attentionCount, + successorCount: successorCount, + reason: reason, + ); + } + return AccountStrategyOutboxSummary( + accountId: accountId, + strategies: summaries, + ); } - Future _serializeWrite(Future Function() action) { + String? _strategyIdForStorageKey( + String storageKey, { + required String accountId, + }) { + final parts = storageKey.split('|'); + if (parts.length < 3 || Uri.decodeComponent(parts.first) != accountId) { + return null; + } + return Uri.decodeComponent(parts[1]); + } + + void _publishAccountSummary() { + if (_isDisposed) return; + final summary = _accountSummary(state.accountId); + final hasDurabilityFailure = _hasDurabilityFailureForCurrentAccount; + if (_sameAccountSummary(state.accountOutbox, summary) && + state.hasDurabilityFailure == hasDurabilityFailure) { + return; + } + state = state.copyWith( + accountOutbox: summary, + hasDurabilityFailure: hasDurabilityFailure, + ); + } + + bool _sameAccountSummary( + AccountStrategyOutboxSummary left, + AccountStrategyOutboxSummary right, + ) { + if (left.accountId != right.accountId || + left.strategies.length != right.strategies.length) { + return false; + } + for (final entry in left.strategies.entries) { + final other = right.strategies[entry.key]; + if (other == null || + other.queuedCount != entry.value.queuedCount || + other.inFlightCount != entry.value.inFlightCount || + other.pausedCount != entry.value.pausedCount || + other.attentionCount != entry.value.attentionCount || + other.successorCount != entry.value.successorCount || + other.reason != entry.value.reason) { + return false; + } + } + return true; + } + + Future _serializeWrite(Future Function() action) { final next = _writeTail.then((_) => action()); - _writeTail = next.catchError((Object error, StackTrace stackTrace) { - log('Outbox write failed: $error', - name: 'strategy_outbox', error: error, stackTrace: stackTrace); - }); + _writeTail = next.then((_) {}).catchError( + (Object error, StackTrace stackTrace) { + log('Outbox write failed: $error', + name: 'strategy_outbox', error: error, stackTrace: stackTrace); + }, + ); return next; } + bool _hasUncertainParkingFor({ + required String? accountId, + required String? strategyPublicId, + }) { + if (accountId == null || strategyPublicId == null) return false; + final prefix = '${Uri.encodeComponent(accountId)}|' + '${Uri.encodeComponent(strategyPublicId)}|'; + return _uncertainOversizedParking.any((key) => key.startsWith(prefix)); + } + + bool get _hasUncertainParkingForActiveStrategy => _hasUncertainParkingFor( + accountId: state.accountId, + strategyPublicId: state.strategyPublicId, + ); + + bool _hasUncertainDurableRecordFor({ + required String? accountId, + required String? strategyPublicId, + }) { + if (accountId == null || strategyPublicId == null) return false; + final prefix = '${Uri.encodeComponent(accountId)}|' + '${Uri.encodeComponent(strategyPublicId)}|'; + return _uncertainDurableRecords.any((key) => key.startsWith(prefix)); + } + + bool get _hasUncertainDurableRecordForActiveStrategy => + _hasUncertainDurableRecordFor( + accountId: state.accountId, + strategyPublicId: state.strategyPublicId, + ); + + bool _hasDurabilityFailureForAccount(String? accountId) { + if (accountId == null) return false; + final prefix = '${Uri.encodeComponent(accountId)}|'; + return _uncertainOversizedParking.any((key) => key.startsWith(prefix)) || + _uncertainDurableRecords.any((key) => key.startsWith(prefix)); + } + + bool get _hasDurabilityFailureForCurrentAccount => + state.loadIssues.isNotEmpty || + _hasDurabilityFailureForAccount(state.accountId); + void _recordPersistenceFailure(Object error, StackTrace stackTrace) { log('Durable outbox persistence failed: $error', name: 'strategy_outbox', error: error, stackTrace: stackTrace); + if (_isDisposed) return; + final queued = Map.from( + state.queuedByEntityKey, + ); + final inFlight = Map.from( + state.inFlightByEntityKey, + ); + final paused = Map.from( + state.pausedByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); + for (final record in _uncertainDurableIntents.values.where((record) => + record.accountId == state.accountId && + record.strategyPublicId == state.strategyPublicId)) { + attention[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + queued.remove(record.entityKey); + inFlight.remove(record.entityKey); + paused.remove(record.entityKey); + } state = state.copyWith( + queuedByEntityKey: queued, + inFlightByEntityKey: inFlight, + pausedByEntityKey: paused, + attentionByEntityKey: attention, isFlushing: false, lastError: 'Cloud work could not be saved to the durable outbox: $error', + hasDurabilityFailure: true, ); } void _scheduleFlush({required bool flushImmediately}) { + if (_isDisposed) return; if (flushImmediately) { unawaited(flushNow()); return; @@ -952,8 +1873,101 @@ class StrategyOpQueueNotifier extends Notifier { _debounceTimer = Timer(_debounceDelay, () => unawaited(flushNow())); } + void retryCurrentAccount() { + if (_isDisposed) return; + _scheduleBackgroundDrain(ignoreBackoff: true); + if (state.queuedByEntityKey.isNotEmpty) { + unawaited(flushNow()); + } + } + + void _scheduleBackgroundDrain({bool ignoreBackoff = false}) { + if (_isDisposed) return; + _backgroundRetryTimer?.cancel(); + final accountId = state.accountId; + if (accountId == null) return; + final activeStrategyId = state.strategyPublicId; + final now = DateTime.now(); + final candidates = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId != activeStrategyId && + (record.status == DurableOutboxStatus.queued || + record.status == DurableOutboxStatus.inFlight) && + !_uncertainDurableRecords.contains(record.storageKey) && + !cloudOperationExceedsPolicy(record.pending.op)) + .toList(growable: false); + if (candidates.isEmpty) return; + final nextAttempt = candidates + .map(_nextAttemptAt) + .reduce((left, right) => left.isBefore(right) ? left : right); + final delay = ignoreBackoff || !nextAttempt.isAfter(now) + ? Duration.zero + : nextAttempt.difference(now); + _backgroundRetryTimer = Timer( + delay, + () => unawaited(_drainNextBackgroundStrategy( + ignoreBackoff: ignoreBackoff, + )), + ); + } + + Future _drainNextBackgroundStrategy({ + required bool ignoreBackoff, + }) async { + if (_isDisposed) return; + if (_networkBusy) { + _backgroundRetryTimer = Timer( + _busyBackgroundRetryDelay, + () => unawaited(_drainNextBackgroundStrategy( + ignoreBackoff: ignoreBackoff, + )), + ); + return; + } + await _writeTail; + if (_isDisposed) return; + final accountId = state.accountId; + if (accountId == null) return; + final activeStrategyId = state.strategyPublicId; + final now = DateTime.now(); + final candidates = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId != activeStrategyId && + (record.status == DurableOutboxStatus.queued || + record.status == DurableOutboxStatus.inFlight) && + !_uncertainDurableRecords.contains(record.storageKey) && + !cloudOperationExceedsPolicy(record.pending.op) && + (ignoreBackoff || !_nextAttemptAt(record).isAfter(now))) + .toList(growable: false) + ..sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); + if (candidates.isEmpty) { + _scheduleBackgroundDrain(); + return; + } + await _flushStrategy( + accountId: accountId, + strategyPublicId: candidates.first.strategyPublicId, + isBackground: true, + ignoreBackoff: ignoreBackoff, + ); + } + + DateTime _nextAttemptAt(DurableOutboxRecord record) { + final lastAttemptAt = record.pending.lastAttemptAt; + if (lastAttemptAt == null || + record.status == DurableOutboxStatus.inFlight) { + return DateTime.fromMillisecondsSinceEpoch(0); + } + final exponent = record.pending.attempts.clamp(0, 6); + return lastAttemptAt.add( + Duration(milliseconds: 300 * (1 << exponent)), + ); + } + void _scheduleRetry(List pending, {Duration? delay}) { - if (pending.isEmpty) return; + if (_isDisposed || pending.isEmpty) return; final maxAttempt = pending.fold( 0, (value, item) => math.max(value, item.attempts), @@ -1235,7 +2249,24 @@ class StrategyOpQueueNotifier extends Notifier { if (loadIssues.isNotEmpty) { return 'The cloud outbox contains unreadable saved work.'; } - if (attention.isNotEmpty) return 'Some saved work needs attention.'; + if (_hasUncertainParkingForActiveStrategy) { + return _uncertainOversizedParkingMessage ?? + 'Cloud work could not be verified in the durable outbox. ' + 'Nothing was sent.'; + } + if (_hasUncertainDurableRecordForActiveStrategy) { + return 'Cloud work could not be verified in the durable outbox.'; + } + if (attention.isNotEmpty) { + final hasOversizedWork = attention.entries.any((entry) { + if (cloudOperationExceedsPolicy(entry.value.pending.op)) return true; + final record = _recordForActiveKey(entry.key); + return record?.pending.op.opId == entry.value.pending.op.opId && + record?.lastError == cloudOperationTooLargeMessage; + }); + if (hasOversizedWork) return cloudOperationTooLargeMessage; + return 'Some saved work needs attention.'; + } if (paused.isNotEmpty) return 'Some saved work is paused after retries.'; return null; } diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index 1de2c408..78c82b5f 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -12,6 +12,7 @@ import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:uuid/uuid.dart'; @@ -144,26 +145,32 @@ class FolderProvider extends Notifier { .firstOrNull; } - Future deleteFolder( + Future deleteFolder( String folderID, { LibraryWorkspace? workspace, }) async { final targetWorkspace = workspace ?? _currentWorkspace; if (targetWorkspace == LibraryWorkspace.cloud) { - try { - await ref.read(convexStrategyRepositoryProvider).deleteFolder(folderID); - } catch (error, stackTrace) { - await _maybeReportCloudUnauthenticated( - source: 'folder:delete', - error: error, - stackTrace: stackTrace, - ); - return; - } + final result = await _runCloudAction( + action: () async { + await ref + .read(convexStrategyRepositoryProvider) + .deleteFolder(folderID); + return true; + }, + source: 'folder:delete', + failureMessage: "Couldn't delete this cloud folder. Try again.", + ); + if (!result.didSucceed) return result; + + await ref.read(pinnedItemsProvider.notifier).removePin(folderID); if (_currentFolderIdForWorkspace(LibraryWorkspace.cloud) == folderID) { updateWorkspaceFolderId(LibraryWorkspace.cloud, null); } - return; + ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudAllFoldersProvider); + ref.invalidate(cloudStrategiesProvider); + return result; } await ref.read(pinnedItemsProvider.notifier).removePin(folderID); @@ -186,9 +193,10 @@ class FolderProvider extends Notifier { } await Hive.box(HiveBoxNames.foldersBox).delete(folderID); + return CloudLibraryActionResult.succeeded; } - void editFolder({ + Future editFolder({ required Folder folder, required String newName, required int newIconId, @@ -201,30 +209,31 @@ class FolderProvider extends Notifier { final newIcon = FolderIconRegistry.resolve(newIconId).iconData; final iconFontFamily = newIcon?.fontFamily; final iconFontPackage = newIcon?.fontPackage; - try { - await ref.read(convexStrategyRepositoryProvider).updateFolder( - folderPublicId: folder.id, - name: newName, - iconId: newIconId, - iconCodePoint: newIcon?.codePoint, - iconFontFamily: iconFontFamily, - clearIconFontFamily: iconFontFamily == null, - iconFontPackage: iconFontPackage, - clearIconFontPackage: iconFontPackage == null, - color: newColor.name, - customColorValue: newCustomColor?.toARGB32(), - clearCustomColorValue: newCustomColor == null, - ); - ref.invalidate(cloudFoldersProvider); - ref.invalidate(cloudAllFoldersProvider); - } catch (error, stackTrace) { - await _maybeReportCloudUnauthenticated( - source: 'folder:update', - error: error, - stackTrace: stackTrace, - ); - } - return; + final result = await _runCloudAction( + action: () async { + await ref.read(convexStrategyRepositoryProvider).updateFolder( + folderPublicId: folder.id, + name: newName, + iconId: newIconId, + iconCodePoint: newIcon?.codePoint, + iconFontFamily: iconFontFamily, + clearIconFontFamily: iconFontFamily == null, + iconFontPackage: iconFontPackage, + clearIconFontPackage: iconFontPackage == null, + color: newColor.name, + customColorValue: newCustomColor?.toARGB32(), + clearCustomColorValue: newCustomColor == null, + ); + return true; + }, + source: 'folder:update', + failureMessage: "Couldn't update this cloud folder. Try again.", + ); + if (!result.didSucceed) return result; + + ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudAllFoldersProvider); + return result; } folder.name = newName; @@ -232,28 +241,33 @@ class FolderProvider extends Notifier { folder.customColor = newCustomColor; folder.color = newColor; await folder.save(); + return CloudLibraryActionResult.succeeded; } - void moveToFolder({ + Future moveToFolder({ required String folderID, String? parentID, LibraryWorkspace? workspace, }) async { final targetWorkspace = workspace ?? _currentWorkspace; if (targetWorkspace == LibraryWorkspace.cloud) { - try { - await ref.read(convexStrategyRepositoryProvider).moveFolder( - folderPublicId: folderID, - parentFolderPublicId: parentID, - ); - } catch (error, stackTrace) { - await _maybeReportCloudUnauthenticated( - source: 'folder:move', - error: error, - stackTrace: stackTrace, - ); - } - return; + final result = await _runCloudAction( + action: () async { + await ref.read(convexStrategyRepositoryProvider).moveFolder( + folderPublicId: folderID, + parentFolderPublicId: parentID, + ); + return true; + }, + source: 'folder:move', + failureMessage: "Couldn't move this cloud folder. Try again.", + showFailureMessage: true, + ); + if (!result.didSucceed) return result; + + ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudAllFoldersProvider); + return result; } final folder = findLocalFolderByID(folderID); @@ -262,6 +276,28 @@ class FolderProvider extends Notifier { folder.parentID = parentID; await folder.save(); } + return CloudLibraryActionResult.succeeded; + } + + Future _runCloudAction({ + required Future Function() action, + required String source, + required String failureMessage, + bool showFailureMessage = false, + }) { + return ref.read(cloudLibraryActionReporterProvider).run( + action: action, + source: source, + failureMessage: failureMessage, + showFailureMessage: showFailureMessage, + reportAuthenticationFailure: (error, stackTrace) => ref + .read(authProvider.notifier) + .reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ), + ); } LibraryWorkspace get _currentWorkspace => ref.read(libraryWorkspaceProvider); diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 47daa80f..d629c356 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -169,6 +169,23 @@ class PlacedImageProvider extends Notifier { tagColorValue: tagColorValue, ); + if (strategySource == StrategySource.cloud && strategyId != null) { + AppErrorReporter.reportInfo( + 'Image enqueueing cloud upload: image=${placedImage.id} ' + 'strategy=$strategyId extension=$fileExtension', + source: 'cloud_media.image_provider', + ); + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .enqueuePlacedImageUpload( + strategyPublicId: strategyId, + imagePublicId: placedImage.id, + fileExtension: fileExtension, + width: null, + height: null, + ); + } + final action = UserAction( type: ActionType.addition, id: placedImage.id, @@ -188,20 +205,12 @@ class PlacedImageProvider extends Notifier { ); if (strategySource == StrategySource.cloud && strategyId != null) { - AppErrorReporter.reportInfo( - 'Image enqueueing cloud upload: image=${placedImage.id} ' - 'strategy=$strategyId extension=$fileExtension', - source: 'cloud_media.image_provider', - ); await ref .read(cloudMediaUploadQueueProvider.notifier) - .enqueuePlacedImageUpload( - strategyPublicId: strategyId, - imagePublicId: placedImage.id, - fileExtension: fileExtension, - width: null, - height: null, - ); + .commitStagedMediaReferences( + strategyPublicId: strategyId, + assetPublicIds: [placedImage.id], + ); } } diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index f483af5b..8bc1e13d 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -109,7 +109,9 @@ final strategyPageSessionProvider = class StrategyPageSessionNotifier extends Notifier { _RemotePageHydrationKey? _lastHydratedRemotePageKey; + RemoteEditorSnapshot? _lastAppliedRemoteSnapshot; bool _pendingRemoteReapply = false; + bool _isResolvingConflicts = false; @override StrategyPageSessionState build() { @@ -297,10 +299,16 @@ class StrategyPageSessionNotifier extends Notifier { .setActivePage(previousPageId); final strategyId = strategyState.strategyId; if (strategyId != null && previousPageId != null) { - ref.read(activePageLiveSyncProvider.notifier).markPageHydrated( - strategyPublicId: strategyId, - pageId: previousPageId, - ); + final snapshot = _lastAppliedRemoteSnapshot; + if (snapshot != null && + snapshot.header.publicId == strategyId && + snapshot.activePage?.page.publicId == previousPageId) { + ref.read(activePageLiveSyncProvider.notifier).markPageHydrated( + strategyPublicId: strategyId, + pageId: previousPageId, + snapshot: snapshot, + ); + } } } catch (_) { // Preserve the original switch failure; the live read can recover @@ -421,6 +429,84 @@ class StrategyPageSessionNotifier extends Notifier { } } + /// Adopts the cloud version for every current conflict in this strategy. + /// + /// The authoritative page is loaded before any local intent is discarded. + /// A failed load therefore leaves the durable conflict available to retry. + Future useCloudVersionsForRejected() async { + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + if (strategyState.source != StrategySource.cloud || strategyId == null) { + return false; + } + + _isResolvingConflicts = true; + try { + await _resolvePageSource(strategyId, StrategySource.cloud) + .flushCurrentPage(); + final strategyNotifier = ref.read(strategyProvider.notifier); + strategyNotifier.consumeScheduledCloudPageSync(); + strategyNotifier.consumeScheduledCloudStrategySync(); + final rejected = Map.from( + ref.read(strategyOpQueueProvider).attentionByEntityKey, + ); + if (rejected.isEmpty) return true; + + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || snapshot.header.publicId != strategyId) { + return false; + } + + final targetPageId = _resolveHydrationTargetPage(snapshot); + if (targetPageId != null) { + final pageSource = CloudStrategyPageSource( + ref, + strategyId: strategyId, + activePageId: () => state.activePageId, + ); + final pageData = await pageSource.loadAuthoritativePage(targetPageId); + await _applyLoadedPageData( + pageData, + strategyId: strategyId, + source: StrategySource.cloud, + hydrationKey: _buildRemotePageHydrationKey(snapshot, targetPageId), + preserveTextDrafts: true, + loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, + ); + } + + final discarded = await ref + .read(strategyOpQueueProvider.notifier) + .discardRejected(rejected.keys.toSet()); + if (discarded.isEmpty) return false; + + ref.read(activePageLiveSyncProvider.notifier).adoptRemoteForEntities( + discarded, + hydratedPageId: targetPageId, + ); + for (final entry in rejected.entries) { + if (discarded.contains(entry.key)) { + ref + .read(strategyConflictProvider.notifier) + .clear(entry.value.pending.op.opId); + } + } + for (final key in discarded) { + if (key.kind == EntitySyncKeyKind.element && key.entityId != null) { + ref.read(textDraftProvider.notifier).clearDraft(key.entityId!); + } + } + ref + .read(strategyOpQueueProvider.notifier) + .completeRemoteAdoption(discarded); + _pendingRemoteReapply = false; + return true; + } finally { + _isResolvingConflicts = false; + } + } + bool get isApplyingPage => state.isApplyingPage; void setStateForTest(StrategyPageSessionState newState) { @@ -435,7 +521,9 @@ class StrategyPageSessionNotifier extends Notifier { isApplyingPage: false, ); _lastHydratedRemotePageKey = null; + _lastAppliedRemoteSnapshot = null; _pendingRemoteReapply = false; + _isResolvingConflicts = false; ref.read(activePageLiveSyncProvider.notifier).reset(); } @@ -476,6 +564,7 @@ class StrategyPageSessionNotifier extends Notifier { pageData, strategyId: strategyId, source: source, + loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, ); if (animated && direction != null) { @@ -486,6 +575,7 @@ class StrategyPageSessionNotifier extends Notifier { Future _rehydrateActivePageFromSource( String pageId, { _RemotePageHydrationKey? hydrationKey, + bool preserveTextDrafts = false, }) async { final strategyState = ref.read(strategyProvider); final strategyId = strategyState.strategyId; @@ -504,13 +594,15 @@ class StrategyPageSessionNotifier extends Notifier { pageId: pageId, ); } - final pageData = - await _resolvePageSource(strategyId, source).loadPage(pageId); + final pageSource = _resolvePageSource(strategyId, source); + final pageData = await pageSource.loadPage(pageId); await _applyLoadedPageData( pageData, strategyId: strategyId, source: source, hydrationKey: hydrationKey, + preserveTextDrafts: preserveTextDrafts, + loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, ); } @@ -519,6 +611,8 @@ class StrategyPageSessionNotifier extends Notifier { required String strategyId, required StrategySource source, _RemotePageHydrationKey? hydrationKey, + bool preserveTextDrafts = false, + RemoteEditorSnapshot? loadedRemoteSnapshot, }) async { final preserveHistory = source == StrategySource.cloud && _lastHydratedRemotePageKey?.strategyPublicId == strategyId && @@ -534,6 +628,9 @@ class StrategyPageSessionNotifier extends Notifier { await _resolvePageSource(strategyId, source).listPageIds(), ); + final retainedTextDrafts = preserveTextDrafts + ? Map.from(ref.read(textDraftProvider)) + : const {}; try { await applyStrategyEditorPageData( ref, @@ -542,15 +639,30 @@ class StrategyPageSessionNotifier extends Notifier { themeOverridePalette: themeOverridePalette, preserveHistory: preserveHistory, ); + for (final entry in retainedTextDrafts.entries) { + ref.read(textDraftProvider.notifier).setDraft(entry.key, entry.value); + } if (source == StrategySource.cloud) { + if (loadedRemoteSnapshot == null) { + throw StateError( + 'Cloud page loaded without its source snapshot.', + ); + } ref.read(activePageLiveSyncProvider.notifier).markPageHydrated( strategyPublicId: strategyId, pageId: pageData.pageId, + snapshot: loadedRemoteSnapshot, ); + _lastAppliedRemoteSnapshot = loadedRemoteSnapshot; } _updateHydrationBookkeeping( pageData.pageId, - hydrationKey: hydrationKey, + hydrationKey: source == StrategySource.cloud + ? _buildRemotePageHydrationKey( + loadedRemoteSnapshot!, + pageData.pageId, + ) + : hydrationKey, ); } finally { state = state.copyWith( @@ -622,7 +734,8 @@ class StrategyPageSessionNotifier extends Notifier { bool _canSafelyReapplyRemotePage() { final saveState = ref.read(strategySaveStateProvider); - return !state.isApplyingPage && + return !_isResolvingConflicts && + !state.isApplyingPage && state.transitionState == PageTransitionState.idle && ref.read(textDraftProvider).isEmpty && !saveState.isDirty && diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 4723c9c7..9647821d 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -33,6 +33,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import 'package:icarus/collab/canonical_json.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/strategy_capabilities.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; @@ -42,6 +43,7 @@ import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/services/cloud_library_action.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -136,7 +138,8 @@ class StrategyProvider extends Notifier { if (!state.isOpen || state.strategyId == null || state.source == null) { return false; } - return !ref.read(strategyPageSessionProvider).isApplyingPage; + return !ref.read(strategyPageSessionProvider).isApplyingPage && + _currentStrategyCanEditPages(); } T _withoutPersistenceTracking(T Function() callback) { @@ -234,6 +237,17 @@ class StrategyProvider extends Notifier { return state.source == StrategySource.cloud; } + bool _currentStrategyCanEditPages() { + if (!_currentStrategyIsCloud()) { + return true; + } + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final role = snapshot?.header.publicId == state.strategyId + ? snapshot?.header.role + : null; + return StrategyCapabilities.fromCloudRole(role).canEditPages; + } + bool _selectedWorkspaceIsCloud() { return ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; } @@ -329,7 +343,9 @@ class StrategyProvider extends Notifier { List ops, { bool flushImmediately = false, }) async { - if (!_currentStrategyIsCloud() || ops.isEmpty) { + if (!_currentStrategyIsCloud() || + !_currentStrategyCanEditPages() || + ops.isEmpty) { return; } @@ -359,7 +375,7 @@ class StrategyProvider extends Notifier { Future notifyCloudMutation({bool flushImmediately = false}) async { _cloudMutationSyncScheduled = false; - if (!_currentStrategyIsCloud()) { + if (!_currentStrategyIsCloud() || !_currentStrategyCanEditPages()) { return; } @@ -376,7 +392,7 @@ class StrategyProvider extends Notifier { bool flushImmediately = false, }) async { _cloudStrategyMutationSyncScheduled = false; - if (!_currentStrategyIsCloud()) { + if (!_currentStrategyIsCloud() || !_currentStrategyCanEditPages()) { return; } @@ -413,6 +429,10 @@ class StrategyProvider extends Notifier { _cloudMutationSyncScheduled = false; } + void consumeScheduledCloudStrategySync() { + _cloudStrategyMutationSyncScheduled = false; + } + void _scheduleCloudStrategySync() { if (_cloudStrategyMutationSyncScheduled) { return; @@ -433,6 +453,9 @@ class StrategyProvider extends Notifier { if (ref.read(strategyPageSessionProvider).isApplyingPage) { return; } + if (!_currentStrategyCanEditPages()) { + return; + } if (_currentStrategyIsCloud()) { ref.read(strategySaveStateProvider.notifier) @@ -494,6 +517,9 @@ class StrategyProvider extends Notifier { if (ref.read(strategyPageSessionProvider).isApplyingPage) { return; } + if (!_currentStrategyCanEditPages()) { + return; + } state = state.copyWith(isSaved: false); @@ -511,6 +537,9 @@ class StrategyProvider extends Notifier { } Future forceSaveNow(String id) async { + if (!_currentStrategyCanEditPages()) { + return; + } cancelPendingSave(); if (_currentStrategyIsCloud()) { ref.read(strategySaveStateProvider.notifier) @@ -615,6 +644,7 @@ class StrategyProvider extends Notifier { } Future reorderPage(int oldIndex, int newIndex) async { + if (!_currentStrategyCanEditPages()) return; if (oldIndex == newIndex) return; if (_currentStrategyIsCloud()) { @@ -865,6 +895,7 @@ class StrategyProvider extends Notifier { } Future addPage([String? name]) async { + if (!_currentStrategyCanEditPages()) return; if (_currentStrategyIsCloud()) { final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null) return; @@ -943,6 +974,7 @@ class StrategyProvider extends Notifier { } Future renamePage(String pageId, String newName) async { + if (!_currentStrategyCanEditPages()) return; final trimmed = newName.trim(); if (trimmed.isEmpty) { return; @@ -986,6 +1018,7 @@ class StrategyProvider extends Notifier { } Future deletePage(String pageId) async { + if (!_currentStrategyCanEditPages()) return; if (_currentStrategyIsCloud()) { final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null || snapshot.pages.length <= 1) { @@ -1423,42 +1456,55 @@ class StrategyProvider extends Notifier { await strategyBox.put(duplicatedStrategy.id, duplicatedStrategy); } - Future deleteStrategy( + Future deleteStrategy( String strategyID, { StrategySource? source, }) async { - await ref.read(pinnedItemsProvider.notifier).removePin(strategyID); final resolvedSource = source ?? _resolveLibraryMutationSource(); if (resolvedSource == StrategySource.cloud) { - try { - final shell = await ref - .read(convexStrategyRepositoryProvider) - .fetchShell(strategyID); - await ref.read(convexStrategyRepositoryProvider).deleteStrategy( - strategyPublicId: strategyID, - expectedRevision: shell.header.revision, - ); - } catch (error, stackTrace) { - final handled = await _reportCloudUnauthenticated( - source: 'strategy:delete', - error: error, - stackTrace: stackTrace, - ); - if (!handled) rethrow; - } + const sourceName = 'strategy:delete'; + final result = await ref.read(cloudLibraryActionReporterProvider).run( + action: () async { + final shell = await ref + .read(convexStrategyRepositoryProvider) + .fetchShell(strategyID); + await ref.read(convexStrategyRepositoryProvider).deleteStrategy( + strategyPublicId: strategyID, + expectedRevision: shell.header.revision, + ); + return true; + }, + source: sourceName, + failureMessage: + "Couldn't delete this cloud strategy. Try again.", + reportAuthenticationFailure: (error, stackTrace) => ref + .read(authProvider.notifier) + .reportConvexUnauthenticated( + source: sourceName, + error: error, + stackTrace: stackTrace, + ), + ); + if (!result.didSucceed) return result; + + await ref.read(pinnedItemsProvider.notifier).removePin(strategyID); ref.invalidate(cloudStrategiesProvider); - return; + return result; } + await ref.read(pinnedItemsProvider.notifier).removePin(strategyID); await Hive.box(HiveBoxNames.strategiesBox).delete(strategyID); final directory = await getApplicationSupportDirectory(); final customDirectory = Directory(path.join(directory.path, strategyID)); - if (!await customDirectory.exists()) return; + if (!await customDirectory.exists()) { + return CloudLibraryActionResult.succeeded; + } await customDirectory.delete(recursive: true); + return CloudLibraryActionResult.succeeded; } Future saveToHive(String id) async { @@ -1577,6 +1623,7 @@ class StrategyProvider extends Notifier { Future _applySettingsToAllPages( StrategySettings Function(StrategySettings settings) transform, ) async { + if (!_currentStrategyCanEditPages()) return; if (_currentStrategyIsCloud()) { final strategyId = state.strategyId; if (strategyId == null) { @@ -1656,42 +1703,54 @@ class StrategyProvider extends Notifier { return StrategySettings(); } - void moveToFolder({ + Future moveToFolder({ required String strategyID, required String? parentID, StrategySource? source, - }) { + }) async { final resolvedSource = source ?? _resolveLibraryMutationSource(); if (resolvedSource == StrategySource.cloud) { - unawaited(() async { - try { - final shell = await ref - .read(convexStrategyRepositoryProvider) - .fetchShell(strategyID); - await ref.read(convexStrategyRepositoryProvider).moveStrategy( - strategyPublicId: strategyID, - folderPublicId: parentID, - expectedRevision: shell.header.revision, - ); - } catch (error, stackTrace) { - await _reportCloudUnauthenticated( - source: 'strategy:move', - error: error, - stackTrace: stackTrace, + const sourceName = 'strategy:move'; + final result = await ref.read(cloudLibraryActionReporterProvider).run( + action: () async { + final shell = await ref + .read(convexStrategyRepositoryProvider) + .fetchShell(strategyID); + await ref.read(convexStrategyRepositoryProvider).moveStrategy( + strategyPublicId: strategyID, + folderPublicId: parentID, + expectedRevision: shell.header.revision, + ); + return true; + }, + source: sourceName, + failureMessage: "Couldn't move this cloud strategy. Try again.", + showFailureMessage: true, + reportAuthenticationFailure: (error, stackTrace) => ref + .read(authProvider.notifier) + .reportConvexUnauthenticated( + source: sourceName, + error: error, + stackTrace: stackTrace, + ), ); - } - }()); + if (!result.didSucceed) return result; + ref.invalidate(cloudStrategiesProvider); - return; + return result; } final strategyBox = Hive.box(HiveBoxNames.strategiesBox); final strategy = strategyBox.get(strategyID); if (strategy != null) { strategy.folderID = parentID; - strategy.save(); + await strategy.save(); + return CloudLibraryActionResult.succeeded; } else { log("Strategy with ID $strategyID not found."); + return CloudLibraryActionResult.failed( + "Couldn't move this strategy. Try again.", + ); } } } diff --git a/lib/providers/strategy_save_state_provider.dart b/lib/providers/strategy_save_state_provider.dart index 1cb40986..8c4c9e3c 100644 --- a/lib/providers/strategy_save_state_provider.dart +++ b/lib/providers/strategy_save_state_provider.dart @@ -94,8 +94,10 @@ class StrategySaveStateNotifier extends Notifier { return; } - final failedJobs = next.jobs.where((job) => job.isFailed).length; - final hasPendingMedia = next.jobs.isNotEmpty; + final strategyId = ref.read(strategyProvider).strategyId; + final strategyJobs = next.jobsForStrategy(strategyId); + final failedJobs = strategyJobs.where((job) => job.isFailed).length; + final hasPendingMedia = strategyJobs.isNotEmpty; final hasPendingCloudSync = state.hasPendingCloudSync || hasPendingMedia; state = state.copyWith( hasPendingMediaSync: hasPendingMedia, diff --git a/lib/services/cloud_library_action.dart b/lib/services/cloud_library_action.dart new file mode 100644 index 00000000..5a3bc9c3 --- /dev/null +++ b/lib/services/cloud_library_action.dart @@ -0,0 +1,108 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; + +enum CloudLibraryActionStatus { + succeeded, + cancelled, + authenticationRequired, + failed, +} + +class CloudLibraryActionResult { + const CloudLibraryActionResult._(this.status, {this.userMessage}); + + static const succeeded = CloudLibraryActionResult._( + CloudLibraryActionStatus.succeeded, + ); + static const cancelled = CloudLibraryActionResult._( + CloudLibraryActionStatus.cancelled, + ); + static const authenticationRequired = CloudLibraryActionResult._( + CloudLibraryActionStatus.authenticationRequired, + userMessage: 'Reconnect to Icarus Cloud, then try again.', + ); + + factory CloudLibraryActionResult.failed(String userMessage) => + CloudLibraryActionResult._( + CloudLibraryActionStatus.failed, + userMessage: userMessage, + ); + + final CloudLibraryActionStatus status; + final String? userMessage; + + bool get didSucceed => status == CloudLibraryActionStatus.succeeded; + bool get wasCancelled => status == CloudLibraryActionStatus.cancelled; +} + +final cloudLibraryActionReporterProvider = Provider( + (_) => const CloudLibraryActionReporter(), +); + +class CloudLibraryActionReporter { + const CloudLibraryActionReporter({ + this.showMessage = _showDefaultMessage, + this.reportTechnicalFailure = _reportDefaultTechnicalFailure, + }); + + final void Function(String message) showMessage; + final void Function({ + required String source, + required Object error, + required StackTrace stackTrace, + }) reportTechnicalFailure; + + Future run({ + required Future Function() action, + required String source, + required String failureMessage, + required Future Function(Object error, StackTrace stackTrace) + reportAuthenticationFailure, + bool showFailureMessage = false, + }) async { + try { + final completed = await action(); + return completed + ? CloudLibraryActionResult.succeeded + : CloudLibraryActionResult.cancelled; + } catch (error, stackTrace) { + if (isConvexUnauthenticatedError(error)) { + await reportAuthenticationFailure(error, stackTrace); + return CloudLibraryActionResult.authenticationRequired; + } + + reportTechnicalFailure( + source: source, + error: error, + stackTrace: stackTrace, + ); + if (showFailureMessage) { + showMessage(failureMessage); + } + return CloudLibraryActionResult.failed(failureMessage); + } + } + + static void _showDefaultMessage(String message) { + Settings.showToast( + message: message, + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + + static void _reportDefaultTechnicalFailure({ + required String source, + required Object error, + required StackTrace stackTrace, + }) { + AppErrorReporter.reportError( + 'Cloud library action failed.', + source: source, + error: error, + stackTrace: stackTrace, + promptUser: false, + ); + } +} diff --git a/lib/services/cloud_sign_out_coordinator.dart b/lib/services/cloud_sign_out_coordinator.dart new file mode 100644 index 00000000..03796735 --- /dev/null +++ b/lib/services/cloud_sign_out_coordinator.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +typedef CloudSignOutPreparation = Future Function(); +typedef CloudEditorClose = Future Function(); +typedef RawSignOut = Future Function(); + +final cloudSignOutPreparationProvider = Provider( + (ref) => () async { + final strategy = ref.read(strategyProvider); + if (strategy.source != StrategySource.cloud || + strategy.strategyId == null) { + return; + } + ref.read(textDraftProvider.notifier).commitAllDrafts(); + await ref + .read(strategyProvider.notifier) + .forceSaveNow(strategy.strategyId!); + // Reconciliation promotes staged media only after the durable strategy op + // proves its exact reference. Network processing continues independently. + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + }, +); + +final cloudEditorCloseProvider = Provider( + (ref) => () async { + if (ref.read(strategyProvider).source == StrategySource.cloud) { + await ref.read(strategyProvider.notifier).clearCurrentStrategy(); + } + }, +); + +final rawSignOutProvider = Provider( + (ref) => () async { + await ref.read(authProvider.notifier).signOut(); + return !ref.read(authProvider).isAuthenticated; + }, +); + +final cloudSignOutRequestProvider = Provider( + (ref) { + var requestInProgress = false; + return (context) async { + if (requestInProgress) return false; + requestInProgress = true; + try { + return await _requestCloudSafeSignOut(context, ref); + } finally { + requestInProgress = false; + } + }; + }, +); + +Future _requestCloudSafeSignOut( + BuildContext context, + Ref ref, +) async { + final accountId = ref.read(authProvider).user?.id; + if (accountId == null) return false; + + try { + await ref.read(cloudSignOutPreparationProvider)(); + } catch (_) { + if (context.mounted) await _showPersistenceBlocked(context); + return false; + } + + final strategy = ref.read(strategyProvider); + final saveState = ref.read(strategySaveStateProvider); + final opQueue = ref.read(strategyOpQueueProvider); + final mediaQueue = ref.read(cloudMediaUploadQueueProvider); + final currentStrategyId = + strategy.source == StrategySource.cloud ? strategy.strategyId : null; + final stagedMedia = mediaQueue.jobs + .where((job) => + job.accountId == accountId && + currentStrategyId == job.strategyPublicId && + !job.referenceDurable) + .toList(growable: false); + final hasUnstagedActiveWork = ref.read(textDraftProvider).isNotEmpty || + stagedMedia.isNotEmpty || + saveState.isSaving || + (saveState.isDirty && + opQueue.pending.isEmpty && + mediaQueue.jobsForStrategy(currentStrategyId).isEmpty); + if (!opQueue.outboxIsReliable || + !mediaQueue.outboxIsReliable || + hasUnstagedActiveWork) { + if (context.mounted) await _showPersistenceBlocked(context); + return false; + } + + final strategyIds = { + ...opQueue.accountOutbox.strategies.keys, + for (final job in mediaQueue.jobs) + if (job.accountId == accountId) job.strategyPublicId, + }; + final workCount = opQueue.accountOutbox.workCount + + mediaQueue.jobs.where((job) => job.accountId == accountId).length; + if (!context.mounted) return false; + final confirmed = await _showSignOutConfirmation( + context, + workCount: workCount, + strategyIds: strategyIds, + currentStrategyId: currentStrategyId, + currentStrategyName: strategy.strategyName, + strategyNames: ref.read(cloudStrategyNamesProvider), + ); + if (!confirmed) return false; + + try { + await ref.read(cloudEditorCloseProvider)(); + } catch (_) { + if (context.mounted) await _showPersistenceBlocked(context); + return false; + } + final signedOut = await ref.read(rawSignOutProvider)(); + if (!signedOut) { + if (context.mounted) { + await _showSignOutFailed( + context, + ref.read(authProvider).errorMessage, + ); + } + return false; + } + if (context.mounted) { + Navigator.of(context).popUntil((route) => route.isFirst); + } + return true; +} + +Future _showPersistenceBlocked(BuildContext context) { + return showShadDialog( + context: context, + barrierDismissible: false, + builder: (context) => ShadDialog.alert( + title: const Text("Can't sign out yet"), + description: const Padding( + padding: EdgeInsets.all(8), + child: Text( + 'Icarus could not confirm that all pending cloud work is saved on ' + 'this device. Stay signed in and try again.', + ), + ), + actions: [ + ShadButton( + key: const ValueKey('sign-out-persistence-blocked'), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Stay Signed In'), + ), + ], + ), + ); +} + +Future _showSignOutConfirmation( + BuildContext context, { + required int workCount, + required Set strategyIds, + required String? currentStrategyId, + required String? currentStrategyName, + required Map strategyNames, +}) async { + final hasPendingWork = workCount > 0; + final strategyLabels = strategyIds + .map((id) => id == currentStrategyId && + currentStrategyName?.trim().isNotEmpty == true + ? currentStrategyName!.trim() + : (strategyNames[id]?.trim().isNotEmpty == true + ? strategyNames[id]!.trim() + : 'a cloud strategy')) + .toSet() + .join(', '); + final result = await showShadDialog( + context: context, + barrierDismissible: false, + builder: (context) => ShadDialog.alert( + title: Text(hasPendingWork ? 'Cloud work is still waiting' : 'Sign out?'), + description: Padding( + padding: const EdgeInsets.all(8), + child: Text( + hasPendingWork + ? '$workCount saved ${workCount == 1 ? 'change' : 'changes'} ' + 'across ${strategyIds.length} ' + '${strategyIds.length == 1 ? 'strategy is' : 'strategies are'} ' + 'still waiting: $strategyLabels. The work remains on this ' + 'device and resumes only when this same account signs in ' + 'again.' + : 'Cloud strategies stay online. Pending work on this device ' + 'will remain tied to this account.', + ), + ), + actions: [ + ShadButton.secondary( + key: const ValueKey('sign-out-cancel'), + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ShadButton.destructive( + key: const ValueKey('sign-out-confirm'), + onPressed: () => Navigator.of(context).pop(true), + child: Text(hasPendingWork ? 'Sign Out Anyway' : 'Sign Out'), + ), + ], + ), + ); + return result ?? false; +} + +Future _showSignOutFailed(BuildContext context, String? message) { + return showShadDialog( + context: context, + builder: (context) => ShadDialog.alert( + title: const Text('Sign out failed'), + description: Padding( + padding: const EdgeInsets.all(8), + child: Text(message ?? 'Icarus could not sign out. Please try again.'), + ), + actions: [ + ShadButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); +} diff --git a/lib/services/cloud_strategy_export.dart b/lib/services/cloud_strategy_export.dart new file mode 100644 index 00000000..24dcd745 --- /dev/null +++ b/lib/services/cloud_strategy_export.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; + +Future runCloudStrategyExport( + WidgetRef ref, + String strategyId, +) { + const source = 'strategy:export'; + return ref.read(cloudLibraryActionReporterProvider).run( + action: () => ref.read(cloudStrategyExporterProvider)(strategyId), + source: source, + failureMessage: "Couldn't export this cloud strategy. Try again.", + showFailureMessage: true, + reportAuthenticationFailure: (error, stackTrace) => + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ), + ); +} diff --git a/lib/services/guarded_sign_out.dart b/lib/services/guarded_sign_out.dart new file mode 100644 index 00000000..3d586495 --- /dev/null +++ b/lib/services/guarded_sign_out.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +typedef GuardedSignOutRequest = Future Function(BuildContext context); + +/// The app shell replaces this with the cloud-aware implementation. +/// +/// Keeping the default fail-closed lets auth UI depend on one contract without +/// introducing a provider import cycle through the editor state. +final guardedSignOutRequestProvider = Provider( + (ref) => (context) async { + await showShadDialog( + context: context, + builder: (context) => ShadDialog.alert( + title: const Text("Can't sign out yet"), + description: const Padding( + padding: EdgeInsets.all(8), + child: Text( + 'Icarus could not verify pending cloud work. Stay signed in and ' + 'try again.', + ), + ), + actions: [ + ShadButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Stay Signed In'), + ), + ], + ), + ); + return false; + }, +); diff --git a/lib/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index 411f50cf..708d890a 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/cloud_sync_error_message.dart'; -import 'package:icarus/collab/convex_client.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -20,7 +20,7 @@ enum UnsavedStrategyDecision { enum CloudExitDecision { stay, - cancelUpload, + leaveAnyway, retrySync, retryAuth, } @@ -69,7 +69,7 @@ Future showUnsavedStrategyDialog( Future _showCloudSyncBlockedDialog( BuildContext context, { required String message, - required bool showCancelUpload, + required bool allowLeaveAnyway, required bool showRetryAuth, }) async { final result = await showShadDialog( @@ -77,6 +77,7 @@ Future _showCloudSyncBlockedDialog( builder: (context) { return ShadDialog.alert( title: const Text('Cloud sync pending'), + actionsAxis: Axis.vertical, description: Padding( padding: const EdgeInsets.all(8), child: Text(message), @@ -95,12 +96,12 @@ Future _showCloudSyncBlockedDialog( }, child: const Text('Retry Convex Auth'), ), - if (showCancelUpload) - ShadButton.destructive( + if (allowLeaveAnyway) + ShadButton.secondary( onPressed: () { - Navigator.of(context).pop(CloudExitDecision.cancelUpload); + Navigator.of(context).pop(CloudExitDecision.leaveAnyway); }, - child: const Text('Cancel Upload'), + child: const Text('Leave Anyway'), ), ShadButton( onPressed: () { @@ -123,12 +124,17 @@ Future _waitForCloudSync( }) async { final deadline = DateTime.now().add(timeout); while (DateTime.now().isBefore(deadline)) { + final strategyId = ref.read(strategyProvider).strategyId; final saveState = ref.read(strategySaveStateProvider); final queueState = ref.read(strategyOpQueueProvider); + final mediaQueueState = ref.read(cloudMediaUploadQueueProvider); + final mediaJobs = mediaQueueState.jobsForStrategy(strategyId); if (!saveState.hasPendingCloudSync && !saveState.hasPendingMediaSync && + mediaJobs.isEmpty && queueState.pending.isEmpty && !queueState.isFlushing && + !mediaQueueState.isProcessing && saveState.cloudSyncError == null && saveState.mediaSyncErrorCount == 0) { return true; @@ -144,8 +150,10 @@ Future _guardCloudStrategyExit({ required Future Function() onContinue, }) async { final openingStrategy = ref.read(strategyProvider); - if (ref.read(textDraftProvider).isNotEmpty && + final openingSaveState = ref.read(strategySaveStateProvider); + if ((ref.read(textDraftProvider).isNotEmpty || openingSaveState.isDirty) && openingStrategy.strategyId != null) { + ref.read(textDraftProvider.notifier).commitAllDrafts(); try { await ref .read(strategyProvider.notifier) @@ -167,14 +175,37 @@ Future _guardCloudStrategyExit({ final queueState = ref.read(strategyOpQueueProvider); final authState = ref.read(authProvider); final mediaQueueState = ref.read(cloudMediaUploadQueueProvider); - final hasPendingMediaJobs = - mediaQueueState.jobsForStrategy(strategyState.strategyId).isNotEmpty; + final mediaJobs = mediaQueueState.jobsForStrategy(strategyState.strategyId); + final hasPendingMediaJobs = mediaJobs.isNotEmpty; + final outboxError = !queueState.outboxIsReliable + ? 'Icarus could not confirm that cloud edits are saved on this device.' + : (!mediaQueueState.outboxIsReliable + ? (mediaQueueState.durabilityError ?? + 'Icarus could not confirm that media work is saved on this device.') + : null); final hasPendingSync = saveState.hasPendingCloudSync || saveState.hasPendingMediaSync || hasPendingMediaJobs || queueState.pending.isNotEmpty; - final cloudError = saveState.cloudSyncError ?? queueState.lastError; + final cloudError = + saveState.cloudSyncError ?? queueState.lastError ?? outboxError; + final hasDurablePendingWork = + queueState.pending.isNotEmpty || hasPendingMediaJobs; + final hasUncommittedMediaReferences = mediaJobs.any( + (job) => !job.referenceDurable, + ); + final hasUnstagedWork = ref.read(textDraftProvider).isNotEmpty || + hasUncommittedMediaReferences || + (saveState.isDirty && !hasDurablePendingWork); + final hasUnreadableSavedWork = queueState.loadIssues.isNotEmpty || + mediaQueueState.loadIssues.isNotEmpty; + final canLeaveWithDurableWork = !hasUnstagedWork && + ((hasDurablePendingWork && + queueState.outboxIsReliable && + mediaQueueState.outboxIsReliable) || + hasUnreadableSavedWork); + final isConnected = ref.read(convexConnectionSnapshotProvider); AppErrorReporter.reportInfo( 'Cloud exit guard check: strategy=${strategyState.strategyId} ' 'dirty=${saveState.isDirty} saving=${saveState.isSaving} ' @@ -188,7 +219,10 @@ Future _guardCloudStrategyExit({ 'auth=${authState.isAuthenticated} ' 'userReady=${authState.isConvexUserReady} ' 'authIncident=${authState.hasActiveAuthIncident} ' - 'connected=${ConvexClient.instance.isConnected} ' + 'connected=$isConnected ' + 'durablePending=$hasDurablePendingWork ' + 'uncommittedMediaReferences=$hasUncommittedMediaReferences ' + 'canLeaveWithDurableWork=$canLeaveWithDurableWork ' 'cloudError=${cloudError ?? 'none'}', source: 'cloud_media.exit_guard', ); @@ -204,7 +238,8 @@ Future _guardCloudStrategyExit({ return true; } - if (queueState.isFlushing && cloudError == null) { + if ((queueState.isFlushing || mediaQueueState.isProcessing) && + cloudError == null) { AppErrorReporter.reportInfo( 'Cloud exit guard waiting for active op flush: ' 'strategy=${strategyState.strategyId}', @@ -230,12 +265,14 @@ Future _guardCloudStrategyExit({ final decision = await _showCloudSyncBlockedDialog( context, - message: cloudError != null - ? friendlyCloudSyncError(cloudError) - : (saveState.mediaSyncErrorCount > 0 - ? 'Some media uploads failed. Retry sync or stay here until the queue clears.' - : 'Icarus is still syncing cloud edits and media. Stay on this screen until sync completes.'), - showCancelUpload: hasPendingMediaJobs, + message: _cloudSyncBlockedMessage( + isConnected: isConnected, + cloudError: cloudError, + mediaErrorCount: saveState.mediaSyncErrorCount, + canLeaveWithDurableWork: canLeaveWithDurableWork, + hasUnreadableSavedWork: hasUnreadableSavedWork, + ), + allowLeaveAnyway: canLeaveWithDurableWork, showRetryAuth: authState.hasActiveAuthIncident, ); @@ -255,20 +292,16 @@ Future _guardCloudStrategyExit({ .read(authProvider.notifier) .reinitializeConvexAuth(source: 'cloud_exit_guard'); break; - case CloudExitDecision.cancelUpload: + case CloudExitDecision.leaveAnyway: AppErrorReporter.reportInfo( - 'Cloud exit guard canceling media uploads.', + 'Cloud exit guard leaving with work pending in durable outboxes.', source: 'cloud_media.exit_guard', ); - final strategyId = strategyState.strategyId; - if (strategyId == null) { + if (!canLeaveWithDurableWork || !context.mounted) { return false; } - await ref - .read(cloudMediaUploadQueueProvider.notifier) - .cancelUploadsForStrategy(strategyId); - await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); - break; + await onContinue(); + return true; case CloudExitDecision.retrySync: AppErrorReporter.reportInfo( 'Cloud exit guard retrying sync.', @@ -284,6 +317,32 @@ Future _guardCloudStrategyExit({ } } +String _cloudSyncBlockedMessage({ + required bool isConnected, + required String? cloudError, + required int mediaErrorCount, + required bool canLeaveWithDurableWork, + required bool hasUnreadableSavedWork, +}) { + final base = !isConnected + ? 'Icarus is offline, so these changes have not reached the cloud.' + : (cloudError != null + ? friendlyCloudSyncError(cloudError) + : (mediaErrorCount > 0 + ? 'Some images have not reached the cloud.' + : 'Icarus is still sending cloud edits and images.')); + if (canLeaveWithDurableWork) { + if (hasUnreadableSavedWork) { + return '$base Leaving will not delete the saved device records. You ' + 'can return to this strategy and retry.'; + } + return '$base The pending work is saved on this device. You can leave ' + 'and Icarus will retry it later.'; + } + return '$base Stay here and retry because Icarus could not confirm that ' + 'all pending work is saved on this device.'; +} + Future guardUnsavedStrategyExit({ required BuildContext context, required WidgetRef ref, diff --git a/lib/strategy/strategy_import_export.dart b/lib/strategy/strategy_import_export.dart index 8f8b1c99..8b29f31e 100644 --- a/lib/strategy/strategy_import_export.dart +++ b/lib/strategy/strategy_import_export.dart @@ -7,6 +7,7 @@ import 'package:cross_file/cross_file.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/collab/collab_models.dart'; @@ -52,6 +53,12 @@ String buildLibraryBackupFileName(DateTime timestamp) { '${twoDigit(timestamp.hour)}-${twoDigit(timestamp.minute)}-${twoDigit(timestamp.second)}.zip'; } +typedef CloudStrategyExporter = Future Function(String strategyId); + +final cloudStrategyExporterProvider = Provider( + (ref) => StrategyImportExportService(ref).exportCloudStrategy, +); + class NewerVersionImportException implements Exception { const NewerVersionImportException({ required this.importedVersion, @@ -2426,7 +2433,7 @@ class StrategyImportExportService { } } - Future exportCloudStrategy(String strategyId) async { + Future exportCloudStrategy(String strategyId) async { final snapshot = await ref .read(convexStrategyRepositoryProvider) .fetchFullSnapshot(strategyId); @@ -2438,8 +2445,9 @@ class StrategyImportExportService { fileName: '${sanitizeStrategyFileName(strategy.name)}.ica', allowedExtensions: ['ica'], ); - if (outputFile == null) return; + if (outputFile == null) return false; await zipStrategyData(strategy: strategy, outputFilePath: outputFile); + return true; } Future exportFile(String id) async { diff --git a/lib/strategy/strategy_page_source.dart b/lib/strategy/strategy_page_source.dart index 9496ac0b..93bf3e86 100644 --- a/lib/strategy/strategy_page_source.dart +++ b/lib/strategy/strategy_page_source.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/canonical_json.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/strategy_capabilities.dart'; import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/line_provider.dart'; @@ -29,6 +30,7 @@ import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:uuid/uuid.dart'; abstract class StrategyPageSource { + RemoteEditorSnapshot? get loadedRemoteSnapshot; Future> listPageIds(); Future loadPage(String pageId); Future flushCurrentPage(); @@ -45,6 +47,9 @@ class LocalStrategyPageSource implements StrategyPageSource { final String strategyId; final String? Function() activePageId; + @override + RemoteEditorSnapshot? get loadedRemoteSnapshot => null; + @override Future> listPageIds() async { final strategy = Hive.box(HiveBoxNames.strategiesBox).get( @@ -149,6 +154,10 @@ class CloudStrategyPageSource implements StrategyPageSource { final Ref ref; final String strategyId; final String? Function() activePageId; + RemoteEditorSnapshot? _loadedRemoteSnapshot; + + @override + RemoteEditorSnapshot? get loadedRemoteSnapshot => _loadedRemoteSnapshot; RemoteEditorSnapshot get _snapshot { final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; @@ -166,7 +175,16 @@ class CloudStrategyPageSource implements StrategyPageSource { } @override - Future loadPage(String pageId) async { + Future loadPage(String pageId) => + _loadPage(pageId, projectLocalWork: true); + + Future loadAuthoritativePage(String pageId) => + _loadPage(pageId, projectLocalWork: false); + + Future _loadPage( + String pageId, { + required bool projectLocalWork, + }) async { if (ref .read(remoteEditorSnapshotProvider) .valueOrNull @@ -179,6 +197,7 @@ class CloudStrategyPageSource implements StrategyPageSource { .setActivePage(pageId); } final snapshot = _snapshot; + _loadedRemoteSnapshot = snapshot; final pages = [...snapshot.pages] ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); final page = pages.firstWhere( @@ -186,11 +205,12 @@ class CloudStrategyPageSource implements StrategyPageSource { orElse: () => pages.first, ); - final projected = - ref.read(activePageLiveSyncProvider.notifier).projectPageState( + final projected = projectLocalWork + ? ref.read(activePageLiveSyncProvider.notifier).projectPageState( strategyPublicId: strategyId, pageId: page.publicId, - ); + ) + : null; if (projected != null && (page.publicId == activePageId() || ref @@ -309,6 +329,13 @@ class CloudStrategyPageSource implements StrategyPageSource { @override Future flushCurrentPage() async { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || + snapshot.header.publicId != strategyId || + !StrategyCapabilities.fromCloudRole(snapshot.header.role) + .canEditPages) { + return; + } final pageId = activePageId(); if (pageId == null) { return; diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index fd53575e..239a1c5d 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -20,6 +20,7 @@ import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/delete_capture.dart'; import 'package:icarus/widgets/demo_tag.dart'; import 'package:icarus/widgets/strategy_view_skeleton.dart'; +import 'package:icarus/widgets/strategy_edit_boundary.dart'; import 'package:icarus/widgets/strategy_quick_switcher.dart'; import 'package:icarus/widgets/map_selector.dart'; import 'package:icarus/widgets/pages_bar.dart'; @@ -235,7 +236,10 @@ class _StrategyViewState extends ConsumerState icon: const Icon(Icons.home), ), const SizedBox(width: 5), - const MapSelector(), + const StrategyEditBoundary( + disabledOpacity: 0.55, + child: MapSelector(), + ), if (kIsWeb) const Padding( padding: EdgeInsets.symmetric(horizontal: 8.0), @@ -284,7 +288,9 @@ class _StrategyViewState extends ConsumerState child: Stack( clipBehavior: Clip.none, children: [ - Positioned.fill(child: DeleteCapture()), + Positioned.fill( + child: StrategyEditBoundary(child: DeleteCapture()), + ), Align( alignment: Alignment.centerLeft, child: RepaintBoundary(child: InteractiveMap()), @@ -297,7 +303,13 @@ class _StrategyViewState extends ConsumerState child: PagesBar(), ), ), - Align(alignment: Alignment.centerRight, child: SideBarUI()), + Align( + alignment: Alignment.centerRight, + child: StrategyEditBoundary( + disabledOpacity: 0.55, + child: SideBarUI(), + ), + ), ], ), ), diff --git a/lib/widgets/cloud_outbox_summary_banner.dart b/lib/widgets/cloud_outbox_summary_banner.dart new file mode 100644 index 00000000..96b0b514 --- /dev/null +++ b/lib/widgets/cloud_outbox_summary_banner.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/strategy_view.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class CloudOutboxSummaryBanner extends ConsumerWidget { + const CloudOutboxSummaryBanner({super.key, this.onOpenStrategy}); + + final ValueChanged? onOpenStrategy; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (ref.watch(libraryWorkspaceProvider) != LibraryWorkspace.cloud) { + return const SizedBox.shrink(); + } + final opQueue = ref.watch(strategyOpQueueProvider); + final mediaQueue = ref.watch(cloudMediaUploadQueueProvider); + final auth = ref.watch(authProvider); + final strategyNames = ref.watch(cloudStrategyNamesProvider); + final connected = ref.watch(convexConnectionProvider).valueOrNull ?? true; + final strategyIds = { + ...opQueue.accountOutbox.strategies.keys, + for (final job in mediaQueue.jobs) job.strategyPublicId, + }; + final workCount = opQueue.accountOutbox.workCount + mediaQueue.jobs.length; + final failedMediaByStrategy = {}; + for (final job in mediaQueue.jobs.where((job) => job.isFailed)) { + failedMediaByStrategy.update( + job.strategyPublicId, + (count) => count + 1, + ifAbsent: () => 1, + ); + } + final attentionIds = { + for (final summary in opQueue.accountOutbox.strategies.values) + if (summary.needsAttention) summary.strategyPublicId, + ...failedMediaByStrategy.keys, + }; + final hasDurabilityProblem = !opQueue.outboxIsReliable || + !mediaQueue.outboxIsReliable || + opQueue.loadIssues.isNotEmpty || + mediaQueue.loadIssues.isNotEmpty; + final authBlocked = workCount > 0 && + (auth.hasActiveAuthIncident || !auth.isConvexUserReady); + if (workCount == 0 && !hasDurabilityProblem) { + return const SizedBox.shrink(); + } + + final needsAttention = + hasDurabilityProblem || authBlocked || attentionIds.isNotEmpty; + final title = needsAttention + ? 'Cloud work needs attention' + : connected + ? 'Syncing cloud work' + : 'Working offline'; + final detail = hasDurabilityProblem + ? 'Icarus cannot read some saved cloud work on this device. Stay ' + 'signed in and review it.' + : authBlocked + ? 'Reconnect this account before Icarus can send its saved work.' + : needsAttention + ? '$workCount saved ${workCount == 1 ? 'change needs' : 'changes need'} ' + 'review across ${strategyIds.length} ' + '${strategyIds.length == 1 ? 'strategy' : 'strategies'}.' + : connected + ? '$workCount saved ${workCount == 1 ? 'change is' : 'changes are'} ' + 'being sent from ${strategyIds.length} ' + '${strategyIds.length == 1 ? 'strategy' : 'strategies'}.' + : '$workCount saved ${workCount == 1 ? 'change is' : 'changes are'} ' + 'waiting on this device and will resume when the ' + 'connection returns.'; + final theme = ShadTheme.of(context); + return Container( + key: const ValueKey('cloud-outbox-summary'), + margin: const EdgeInsets.fromLTRB(24, 16, 24, 0), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + border: Border.all(color: Settings.tacticalVioletTheme.border), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + needsAttention + ? LucideIcons.circleAlert + : connected + ? LucideIcons.cloudUpload + : LucideIcons.cloudOff, + size: 18, + color: needsAttention + ? theme.colorScheme.destructive + : theme.colorScheme.mutedForeground, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + detail, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + if (attentionIds.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final strategyId in attentionIds) + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: () => _openStrategy(context, strategyId), + child: Text(_attentionLabel( + strategyId, + strategyNames[strategyId], + opQueue + .accountOutbox.strategies[strategyId]?.reason, + failedMediaByStrategy[strategyId] ?? 0, + )), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ); + } + + void _openStrategy(BuildContext context, String strategyId) { + final callback = onOpenStrategy; + if (callback != null) { + callback(strategyId); + return; + } + Navigator.of(context).push( + StrategyView.route( + initialStrategyId: strategyId, + initialStrategySource: StrategySource.cloud, + ), + ); + } + + String _attentionLabel( + String id, + String? strategyName, + String? reason, + int failedMediaCount, + ) { + final label = strategyName?.trim().isNotEmpty == true + ? strategyName!.trim() + : 'A cloud strategy'; + if (reason != null && reason.isNotEmpty) { + return '$label: review sync'; + } + if (failedMediaCount > 0) { + return '$label: $failedMediaCount ' + '${failedMediaCount == 1 ? 'image failed' : 'images failed'}'; + } + return '$label: review'; + } +} diff --git a/lib/widgets/cloud_sync_status_chip.dart b/lib/widgets/cloud_sync_status_chip.dart index 375cd723..27a25b09 100644 --- a/lib/widgets/cloud_sync_status_chip.dart +++ b/lib/widgets/cloud_sync_status_chip.dart @@ -1,16 +1,17 @@ import 'dart:async'; +import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/cloud_sync_error_message.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; -import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/cloud_sync_status_provider.dart'; import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; -import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -37,6 +38,8 @@ class _CloudSyncStatusChipState extends ConsumerState { final ShadPopoverController _popoverController = ShadPopoverController(); DateTime? _lastConflictToast; Timer? _pendingConflictToast; + bool _isResolving = false; + String? _resolutionError; @override void dispose() { @@ -81,15 +84,60 @@ class _CloudSyncStatusChipState extends ConsumerState { } Future _retry() async { + if (_isResolving) return; + setState(() { + _isResolving = true; + _resolutionError = null; + }); _popoverController.hide(); - await ref - .read(cloudMediaUploadQueueProvider.notifier) - .retryNow(ignoreBackoff: true); - final opQueue = ref.read(strategyOpQueueProvider.notifier); - await opQueue.retryPaused(flushImmediately: false); - await opQueue.retryRejected(flushImmediately: false); - await opQueue.flushNow(); - opQueue.clearStaleError(); + try { + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + final opQueue = ref.read(strategyOpQueueProvider.notifier); + await opQueue.retryPaused(flushImmediately: false); + await opQueue.retryRejected(flushImmediately: false); + await opQueue.flushNow(); + opQueue.clearStaleError(); + } finally { + if (mounted) setState(() => _isResolving = false); + } + } + + Future _useCloudVersions() async { + if (_isResolving) return; + setState(() { + _isResolving = true; + _resolutionError = null; + }); + String? resolutionError; + try { + final resolved = await ref + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + if (resolved) { + _popoverController.hide(); + } else { + resolutionError = 'Could not load the cloud version. ' + 'Your saved version was not changed.'; + } + } catch (error, stackTrace) { + log( + 'Failed to use the cloud version: $error', + name: 'cloud_conflict_resolution', + error: error, + stackTrace: stackTrace, + ); + resolutionError = 'Could not load the cloud version. ' + 'Your saved version was not changed.'; + } finally { + if (mounted) { + setState(() { + _isResolving = false; + _resolutionError = resolutionError; + }); + } + } } @override @@ -105,28 +153,35 @@ class _CloudSyncStatusChipState extends ConsumerState { final saveState = ref.watch(strategySaveStateProvider); final opQueueState = ref.watch(strategyOpQueueProvider); - final hasTextDrafts = ref.watch( - textDraftProvider.select((drafts) => drafts.isNotEmpty), + final mediaQueueState = ref.watch(cloudMediaUploadQueueProvider); + final activeStrategyId = ref.watch( + strategyProvider.select((state) => state.strategyId), ); - final isConnected = ref.watch(convexConnectionProvider).valueOrNull ?? true; - - final _SyncStatus status; - if (opQueueState.needsAttention || + final hasOtherStrategyWork = opQueueState.accountOutbox.strategies.values + .any((summary) => summary.strategyPublicId != activeStrategyId) || + mediaQueueState.jobs.any( + (job) => job.strategyPublicId != activeStrategyId, + ); + final hasOtherStrategyAttention = + opQueueState.accountOutbox.strategies.values.any((summary) => + summary.strategyPublicId != activeStrategyId && + summary.needsAttention) || + mediaQueueState.jobs.any( + (job) => job.strategyPublicId != activeStrategyId && job.isFailed, + ); + final hasActiveStrategyAttention = opQueueState.needsAttention || saveState.cloudSyncError != null || - saveState.mediaSyncErrorCount > 0) { - status = _SyncStatus.attention; - } else if (!isConnected) { - status = _SyncStatus.offline; - } else if (hasTextDrafts) { - status = _SyncStatus.editing; - } else if (saveState.isSaving || - saveState.hasPendingCloudSync || - saveState.hasPendingMediaSync || - !opQueueState.durableLoaded) { - status = _SyncStatus.syncing; - } else { - status = _SyncStatus.synced; - } + saveState.mediaSyncErrorCount > 0 || + mediaQueueState.jobs.any( + (job) => job.strategyPublicId == activeStrategyId && job.isFailed, + ); + final status = switch (ref.watch(cloudSyncStatusProvider)) { + CloudSyncStatus.synced => _SyncStatus.synced, + CloudSyncStatus.editing => _SyncStatus.editing, + CloudSyncStatus.syncing => _SyncStatus.syncing, + CloudSyncStatus.offline => _SyncStatus.offline, + CloudSyncStatus.attention => _SyncStatus.attention, + }; return ShadPopover( controller: _popoverController, @@ -139,8 +194,14 @@ class _CloudSyncStatusChipState extends ConsumerState { popover: (context) => _SyncStatusPopover( status: status, saveState: saveState, - hasRejectedWork: opQueueState.attentionByEntityKey.isNotEmpty, + rejectedCount: opQueueState.attentionByEntityKey.length, + hasOtherStrategyWork: hasOtherStrategyWork, + hasOtherStrategyAttention: hasOtherStrategyAttention, + hasActiveStrategyAttention: hasActiveStrategyAttention, + isResolving: _isResolving, + resolutionError: _resolutionError, onRetry: _retry, + onUseCloudVersions: _useCloudVersions, ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), @@ -280,14 +341,28 @@ class _SyncStatusPopover extends StatelessWidget { const _SyncStatusPopover({ required this.status, required this.saveState, - required this.hasRejectedWork, + required this.rejectedCount, + required this.hasOtherStrategyWork, + required this.hasOtherStrategyAttention, + required this.hasActiveStrategyAttention, + required this.isResolving, + required this.resolutionError, required this.onRetry, + required this.onUseCloudVersions, }); final _SyncStatus status; final StrategySaveState saveState; - final bool hasRejectedWork; + final int rejectedCount; + final bool hasOtherStrategyWork; + final bool hasOtherStrategyAttention; + final bool hasActiveStrategyAttention; + final bool isResolving; + final String? resolutionError; final Future Function() onRetry; + final Future Function() onUseCloudVersions; + + bool get hasRejectedWork => rejectedCount > 0; @override Widget build(BuildContext context) { @@ -314,6 +389,16 @@ class _SyncStatusPopover extends StatelessWidget { height: 1.35, ), ), + if (resolutionError != null) ...[ + const SizedBox(height: 8), + Text( + resolutionError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + height: 1.35, + ), + ), + ], if (lastSynced != null) ...[ const SizedBox(height: 8), Text( @@ -324,13 +409,25 @@ class _SyncStatusPopover extends StatelessWidget { ), ), ], - if (status == _SyncStatus.attention) ...[ + if (status == _SyncStatus.attention && + (!hasOtherStrategyAttention || hasActiveStrategyAttention)) ...[ const SizedBox(height: 12), + if (hasRejectedWork) ...[ + ShadButton.secondary( + size: ShadButtonSize.sm, + expands: false, + onPressed: isResolving ? null : onUseCloudVersions, + child: const Text('Use cloud'), + ), + const SizedBox(height: 8), + ], ShadButton( size: ShadButtonSize.sm, - onPressed: onRetry, - leading: const Icon(LucideIcons.refreshCw, size: 14), - child: Text(hasRejectedWork ? 'Keep my version' : 'Retry sync'), + expands: false, + onPressed: isResolving ? null : onRetry, + child: Text( + hasRejectedWork ? 'Keep mine' : 'Retry sync', + ), ), ], ], @@ -361,32 +458,59 @@ class _SyncStatusPopover extends StatelessWidget { return 'Finish editing or switch pages to send this change to the ' 'cloud.'; case _SyncStatus.syncing: - return 'Your edits are being sent to the cloud. You can keep ' - 'working — this happens in the background.'; + return hasOtherStrategyWork + ? 'Saved changes from your cloud library are being sent in the ' + 'background.' + : 'Your edits are being sent to the cloud. You can keep ' + 'working — this happens in the background.'; case _SyncStatus.offline: return 'Changes are kept on this device and will sync automatically ' 'when your connection returns.'; case _SyncStatus.attention: - return _attentionExplanation; + const otherStrategyExplanation = + 'Saved work in another strategy also needs attention. Open it ' + 'from the Cloud library to review the reason.'; + if (!hasOtherStrategyAttention) return _attentionExplanation; + if (hasActiveStrategyAttention) { + return '$_attentionExplanation $otherStrategyExplanation'; + } + return otherStrategyExplanation.replaceFirst(' also', ''); } } String get _attentionExplanation { final mediaErrors = saveState.mediaSyncErrorCount; final parts = []; - if (hasRejectedWork) { + final error = saveState.cloudSyncError; + final hasOversizedWork = + error?.toLowerCase().contains('too large for cloud sync') ?? false; + if (hasRejectedWork && !hasOversizedWork) { parts.add( 'Another edit reached the cloud first. Your version remains saved ' 'on this device.', ); + parts.add( + rejectedCount == 1 + ? 'Choose which version to keep for this conflicting change.' + : 'Your choice applies to all $rejectedCount conflicting changes.', + ); } - final error = saveState.cloudSyncError; final retryUnavailable = error?.toLowerCase().contains('cannot be retried automatically') ?? false; - if (error != null && (!hasRejectedWork || retryUnavailable)) { + if (error != null && + (!hasRejectedWork || retryUnavailable || hasOversizedWork)) { parts.add(friendlyCloudSyncError(error)); } + if (hasRejectedWork && hasOversizedWork) { + parts.add( + rejectedCount == 1 + ? 'Choose whether to keep this local change or use the cloud ' + 'version.' + : 'Your choice applies to all $rejectedCount changes that need ' + 'attention.', + ); + } if (mediaErrors > 0) { parts.add( mediaErrors == 1 @@ -397,11 +521,7 @@ class _SyncStatusPopover extends StatelessWidget { if (parts.isEmpty) { parts.add("Some changes haven't reached the cloud yet."); } - parts.add( - hasRejectedWork - ? 'Choose Keep my version to send your retained edit again.' - : 'Retry to send them now.', - ); + if (!hasRejectedWork) parts.add('Retry to send them now.'); return parts.join(' '); } diff --git a/lib/widgets/current_path_bar.dart b/lib/widgets/current_path_bar.dart index 2b73f039..36dd83a4 100644 --- a/lib/widgets/current_path_bar.dart +++ b/lib/widgets/current_path_bar.dart @@ -110,10 +110,10 @@ class FolderTab extends ConsumerWidget { child: Text(displayName), ); }, - onAcceptWithDetails: (details) { + onAcceptWithDetails: (details) async { final item = details.data; if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( + await ref.read(strategyProvider.notifier).moveToFolder( strategyID: item.strategyId, parentID: folder?.id, source: item.strategy == null @@ -121,7 +121,7 @@ class FolderTab extends ConsumerWidget { : StrategySource.local, ); } else if (item is FolderItem) { - ref.read(folderProvider.notifier).moveToFolder( + await ref.read(folderProvider.notifier).moveToFolder( folderID: item.folder.id, parentID: folder?.id, workspace: ref.read(libraryWorkspaceProvider), diff --git a/lib/widgets/dialogs/create_lineup_dialog.dart b/lib/widgets/dialogs/create_lineup_dialog.dart index edee1fb0..8d997b08 100644 --- a/lib/widgets/dialogs/create_lineup_dialog.dart +++ b/lib/widgets/dialogs/create_lineup_dialog.dart @@ -36,6 +36,7 @@ class _CreateLineupDialogState extends ConsumerState { final TextEditingController _youtubeLinkController = TextEditingController(); final TextEditingController _notesController = TextEditingController(); final List _imagePaths = []; + final Set _initialImageIds = {}; Future _enqueueLineupMediaJobs({ required List images, @@ -46,15 +47,29 @@ class _CreateLineupDialogState extends ConsumerState { return; } - for (final image in images) { - await ref - .read(cloudMediaUploadQueueProvider.notifier) - .enqueueJobForLocalFile( - strategyPublicId: strategyState.strategyId!, - assetPublicId: image.id, - fileExtension: image.fileExtension, - ); + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueLineupMediaJobs( + strategyPublicId: strategyState.strategyId!, + images: images, + ); + } + + Future _commitLineupMediaJobs({ + required List images, + }) async { + final strategyState = ref.read(strategyProvider); + if (strategyState.source != StrategySource.cloud || + strategyState.strategyId == null || + images.isEmpty) { + return; } + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .commitStagedMediaReferences( + strategyPublicId: strategyState.strategyId!, + assetPublicIds: images.map((image) => image.id), + ); } bool get _isEditing => @@ -72,6 +87,7 @@ class _CreateLineupDialogState extends ConsumerState { _youtubeLinkController.text = item.youtubeLink; _notesController.text = item.notes; _imagePaths.addAll(item.images); + _initialImageIds.addAll(item.images.map((image) => image.id)); } } } @@ -86,32 +102,53 @@ class _CreateLineupDialogState extends ConsumerState { Future _save() async { final lineUpState = ref.read(lineUpProvider); final notifier = ref.read(lineUpProvider.notifier); + final existingItem = _isEditing + ? notifier.getItemById( + groupId: widget.lineUpGroupId!, + itemId: widget.lineUpItemId!, + ) + : null; + if (_isEditing && existingItem == null) { + return; + } + if (!_isEditing && lineUpState.currentAbility == null) { + return; + } + if (!_isEditing && + lineUpState.currentGroupId == null && + lineUpState.currentAgent == null) { + return; + } + final imagesNeedingUpload = _imagePaths + .where((image) => !_initialImageIds.contains(image.id)) + .toList(growable: false); + try { + await _enqueueLineupMediaJobs(images: imagesNeedingUpload); + } catch (_) { + Settings.showToast( + message: 'Could not queue these images for cloud sync. ' + 'They remain on this device. Try again before closing.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } if (_isEditing) { - final existingItem = notifier.getItemById( - groupId: widget.lineUpGroupId!, - itemId: widget.lineUpItemId!, + ref.read(actionProvider.notifier).performTransaction( + groups: const [ActionGroup.lineUp], + mutation: () { + notifier.updateItem( + groupId: widget.lineUpGroupId!, + item: existingItem!.copyWith( + youtubeLink: _youtubeLinkController.text, + notes: _notesController.text, + images: _imagePaths, + ), + ); + }, ); - if (existingItem != null) { - ref.read(actionProvider.notifier).performTransaction( - groups: const [ActionGroup.lineUp], - mutation: () { - notifier.updateItem( - groupId: widget.lineUpGroupId!, - item: existingItem.copyWith( - youtubeLink: _youtubeLinkController.text, - notes: _notesController.text, - images: _imagePaths, - ), - ); - }, - ); - } } else { - final currentAbility = lineUpState.currentAbility; - if (currentAbility == null) { - return; - } + final currentAbility = lineUpState.currentAbility!; final item = LineUpItem( id: const Uuid().v4(), @@ -136,10 +173,7 @@ class _CreateLineupDialogState extends ConsumerState { }, ); } else { - final currentAgent = lineUpState.currentAgent; - if (currentAgent == null) { - return; - } + final currentAgent = lineUpState.currentAgent!; final groupId = const Uuid().v4(); notifier.addGroup( @@ -167,7 +201,15 @@ class _CreateLineupDialogState extends ConsumerState { ); } - await _enqueueLineupMediaJobs(images: _imagePaths); + try { + await _commitLineupMediaJobs(images: imagesNeedingUpload); + } catch (_) { + Settings.showToast( + message: 'The lineup is kept in the editor, but its cloud work is ' + 'still pending. Use Save before leaving.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } ref .read(interactionStateProvider.notifier) diff --git a/lib/widgets/dialogs/delete_folder_alert_dialog.dart b/lib/widgets/dialogs/delete_folder_alert_dialog.dart new file mode 100644 index 00000000..81f8ff66 --- /dev/null +++ b/lib/widgets/dialogs/delete_folder_alert_dialog.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class DeleteFolderAlertDialog extends ConsumerStatefulWidget { + const DeleteFolderAlertDialog({ + super.key, + required this.folder, + required this.workspace, + }); + + final Folder folder; + final LibraryWorkspace workspace; + + @override + ConsumerState createState() => + _DeleteFolderAlertDialogState(); +} + +class _DeleteFolderAlertDialogState + extends ConsumerState { + bool _isDeleting = false; + String? _failureMessage; + + Future _delete() async { + if (_isDeleting) return; + setState(() { + _isDeleting = true; + _failureMessage = null; + }); + + CloudLibraryActionResult? result; + try { + result = await ref.read(folderProvider.notifier).deleteFolder( + widget.folder.id, + workspace: widget.workspace, + ); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + 'Failed to delete a library folder.', + source: 'folder_dialog:delete', + error: error, + stackTrace: stackTrace, + promptUser: false, + ); + if (mounted) { + setState(() { + _failureMessage = "Couldn't delete this folder. Try again."; + }); + } + } finally { + if (mounted) setState(() => _isDeleting = false); + } + if (!mounted) return; + if (result?.didSucceed == true) { + Navigator.of(context).pop(); + return; + } + if (result != null) { + setState(() => _failureMessage = + result!.userMessage ?? "Couldn't delete this folder. Try again."); + } + } + + @override + Widget build(BuildContext context) { + return ShadDialog.alert( + title: Text("Delete '${widget.folder.name}'?"), + description: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'This also removes every strategy and subfolder inside it.', + ), + if (_failureMessage != null) ...[ + const SizedBox(height: 10), + Text( + _failureMessage!, + key: const ValueKey('delete-folder-failure'), + style: TextStyle( + color: Settings.tacticalVioletTheme.destructive, + ), + ), + ], + ], + ), + actions: [ + ShadButton.secondary( + onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ShadButton.destructive( + key: const ValueKey('delete-folder-confirm'), + onPressed: _isDeleting ? null : _delete, + child: Text(_isDeleting ? 'Deleting...' : 'Delete'), + ), + ], + ); + } +} diff --git a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart index b5666c3c..0eea1215 100644 --- a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart +++ b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/cloud_library_action.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -class DeleteStrategyAlertDialog extends ConsumerWidget { +class DeleteStrategyAlertDialog extends ConsumerStatefulWidget { const DeleteStrategyAlertDialog({ super.key, required this.strategyID, @@ -15,54 +17,109 @@ class DeleteStrategyAlertDialog extends ConsumerWidget { final String strategyID; final String name; final StrategySource source; + + @override + ConsumerState createState() => + _DeleteStrategyAlertDialogState(); +} + +class _DeleteStrategyAlertDialogState + extends ConsumerState { + bool _isDeleting = false; + String? _failureMessage; + + Future _delete() async { + if (_isDeleting) return; + setState(() { + _isDeleting = true; + _failureMessage = null; + }); + + CloudLibraryActionResult? result; + try { + result = await ref.read(strategyProvider.notifier).deleteStrategy( + widget.strategyID, + source: widget.source, + ); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + 'Failed to delete a strategy.', + source: 'strategy_dialog:delete', + error: error, + stackTrace: stackTrace, + promptUser: false, + ); + if (mounted) { + setState(() { + _failureMessage = "Couldn't delete this strategy. Try again."; + }); + } + } finally { + if (mounted) setState(() => _isDeleting = false); + } + if (!mounted) return; + if (result?.didSucceed == true) { + Navigator.of(context).pop(); + return; + } + if (result != null) { + setState(() => _failureMessage = + result!.userMessage ?? "Couldn't delete this strategy. Try again."); + } + } + @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return ShadDialog.alert( title: Text.rich( TextSpan( children: [ const TextSpan(text: "Delete "), TextSpan( - text: name, + text: widget.name, style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), ), - description: Text.rich( - TextSpan( - children: [ - const TextSpan(text: "Are you sure you want to delete "), + description: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( TextSpan( - text: name, - style: const TextStyle(fontWeight: FontWeight.bold), + children: [ + const TextSpan(text: "Are you sure you want to delete "), + TextSpan( + text: widget.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: "? This action cannot be undone."), + ], + ), + ), + if (_failureMessage != null) ...[ + const SizedBox(height: 10), + Text( + _failureMessage!, + key: const ValueKey('delete-strategy-failure'), + style: TextStyle( + color: Settings.tacticalVioletTheme.destructive, + ), ), - const TextSpan(text: "? This action cannot be undone."), ], - ), + ], ), actions: [ ShadButton.secondary( - onPressed: () { - Navigator.of(context).pop(); - }, + onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), child: const Text( "Cancel", ), ), ShadButton.destructive( - // padding: WidgetStateProperty.all( - // const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - - onPressed: () async { - await ref - .read(strategyProvider.notifier) - .deleteStrategy(strategyID, source: source); - - if (!context.mounted) return; - - Navigator.of(context).pop(); - }, + key: const ValueKey('delete-strategy-confirm'), + onPressed: _isDeleting ? null : _delete, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -72,7 +129,7 @@ class DeleteStrategyAlertDialog extends ConsumerWidget { ), const SizedBox(width: 5), Text( - "Delete", + _isDeleting ? 'Deleting...' : 'Delete', style: TextStyle( color: Settings.tacticalVioletTheme.destructiveForeground, ), diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart index b64ed0b0..0d0f3da2 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -6,10 +6,11 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_context_menu_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; import 'package:icarus/widgets/drag_tilt_feedback.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; @@ -282,11 +283,11 @@ class _FolderCardState extends ConsumerState } if (item is StrategyItem) { - ref + await ref .read(strategyProvider.notifier) .moveToFolder(strategyID: item.strategy!.id, parentID: _folder.id); } else if (item is FolderItem) { - ref + await ref .read(folderProvider.notifier) .moveToFolder(folderID: item.folder.id, parentID: _folder.id); } @@ -673,19 +674,14 @@ class _FolderCardState extends ConsumerState child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), onPressed: () async { _closeMenus(); - ConfirmAlertDialog.show( + if (widget.isDemo) return; + await showShadDialog( context: context, - title: "Are you sure you want to delete '${_folder.name}' folder?", - content: - "This will also delete all strategies and subfolders within it.", - confirmText: "Delete", - isDestructive: true, - ).then((confirmed) { - if (confirmed) { - if (widget.isDemo) return; - ref.read(folderProvider.notifier).deleteFolder(_folder.id); - } - }); + builder: (_) => DeleteFolderAlertDialog( + folder: _folder, + workspace: ref.read(libraryWorkspaceProvider), + ), + ); }, ), ]; diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index 6753449e..2d6cd998 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/cloud_library_action.dart'; import 'package:icarus/widgets/better_color_picker.dart'; import 'package:icarus/widgets/color_picker_button.dart'; import 'package:icarus/widgets/custom_segmented_tabs.dart'; @@ -37,6 +39,63 @@ class _FolderEditDialogState extends ConsumerState { FolderColor _selectedColor = FolderColor.red; Color? _customColor; _FolderIconFilter _iconFilter = _FolderIconFilter.all; + bool _isSubmitting = false; + String? _failureMessage; + + Future _submit() async { + if (_isSubmitting) return; + setState(() { + _isSubmitting = true; + _failureMessage = null; + }); + + final name = _folderNameController.text.isEmpty + ? 'New Folder' + : _folderNameController.text; + CloudLibraryActionResult? result; + try { + if (widget.folder != null) { + result = await ref.read(folderProvider.notifier).editFolder( + folder: widget.folder!, + newName: name, + newIconId: _selectedIconId, + newColor: _selectedColor, + newCustomColor: _customColor, + ); + } else { + await ref.read(folderProvider.notifier).createFolder( + name: name, + iconId: _selectedIconId, + color: _selectedColor, + customColor: _customColor, + ); + result = CloudLibraryActionResult.succeeded; + } + } catch (error, stackTrace) { + AppErrorReporter.reportError( + 'Failed to save a library folder.', + source: 'folder_dialog:save', + error: error, + stackTrace: stackTrace, + promptUser: false, + ); + if (mounted) { + setState(() { + _failureMessage = "Couldn't save this folder. Try again."; + }); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + if (!mounted) return; + if (result?.didSucceed == true) { + Navigator.of(context).pop(); + } else if (result != null) { + setState(() => _failureMessage = + result!.userMessage ?? "Couldn't save this folder. Try again."); + } + } + @override void dispose() { _folderNameController.dispose(); @@ -74,47 +133,28 @@ class _FolderEditDialogState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: ShadButton( - leading: const Icon(Icons.check), - onPressed: () async { - if (widget.folder != null) { - ref.read(folderProvider.notifier).editFolder( - folder: widget.folder!, - newName: _folderNameController.text.isEmpty - ? "New Folder" - : _folderNameController.text, - newIconId: _selectedIconId, - newColor: _selectedColor, - newCustomColor: _customColor, - ); - if (context.mounted) Navigator.of(context).pop(); - return; - } - try { - await ref.read(folderProvider.notifier).createFolder( - name: _folderNameController.text.isEmpty - ? "New Folder" - : _folderNameController.text, - iconId: _selectedIconId, - color: _selectedColor, - customColor: _customColor, - ); - } catch (_) { - // Cloud folder creation failed (already toasted by the - // provider) — keep the dialog open so the user can retry. - return; - } - - if (context.mounted) Navigator.of(context).pop(); - }, - child: const Text("Done"), + key: const ValueKey('folder-edit-submit'), + leading: _isSubmitting ? null : const Icon(Icons.check), + onPressed: _isSubmitting ? null : _submit, + child: Text(_isSubmitting ? 'Saving...' : 'Done'), ), - ) + ), ], child: SizedBox( width: 358, child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (_failureMessage != null) ...[ + Text( + _failureMessage!, + key: const ValueKey('folder-edit-failure'), + style: TextStyle( + color: Settings.tacticalVioletTheme.destructive, + ), + ), + const SizedBox(height: 10), + ], Container( height: 220, width: 358, diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 5fbf91dc..00665472 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -20,17 +20,18 @@ import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/services/windows_desktop_update_controller.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/current_path_bar.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; import 'package:icarus/widgets/demo_tag.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/dialogs/strategy/create_strategy_dialog.dart'; import 'package:icarus/widgets/dialogs/web_view_dialog.dart'; import 'package:icarus/widgets/account_avatar.dart'; +import 'package:icarus/widgets/cloud_outbox_summary_banner.dart'; import 'package:icarus/widgets/folder_content.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; @@ -464,17 +465,24 @@ class _FolderNavigatorState extends ConsumerState { child: const Text('Create Strategy'), ), ], - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 220), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: KeyedSubtree( - key: ValueKey('$workspace/$cloudSection'), - child: FolderContent( - folder: currentFolder, - onCreateStrategy: showCreateDialog, + child: Column( + children: [ + if (isCloudWorkspace) const CloudOutboxSummaryBanner(), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: KeyedSubtree( + key: ValueKey('$workspace/$cloudSection'), + child: FolderContent( + folder: currentFolder, + onCreateStrategy: showCreateDialog, + ), + ), + ), ), - ), + ], ), ), ), @@ -687,23 +695,9 @@ class _LibraryNavigationRailState extends ConsumerState { ? null : () async { if (authState.isAuthenticated) { - // One accidental click on the avatar used - // to sign out instantly. - final confirmed = - await ConfirmAlertDialog.show( - context: context, - title: 'Sign out?', - content: - 'Cloud strategies stay online; your ' - 'local strategies stay on this ' - 'device.', - confirmText: 'Sign Out', - ); - if (!confirmed || !context.mounted) { - return; - } - unawaited( - ref.read(authProvider.notifier).signOut(), + await ref + .read(guardedSignOutRequestProvider)( + context, ); } else { showDialog( diff --git a/lib/widgets/folder_navigator_sidebar.dart b/lib/widgets/folder_navigator_sidebar.dart index e72c2cbc..545e54e4 100644 --- a/lib/widgets/folder_navigator_sidebar.dart +++ b/lib/widgets/folder_navigator_sidebar.dart @@ -12,7 +12,7 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/custom_search_field.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; import 'package:icarus/widgets/folder_navigator.dart'; @@ -362,10 +362,10 @@ class _SidebarRootItem extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return DragTarget( - onAcceptWithDetails: (details) { + onAcceptWithDetails: (details) async { final item = details.data; if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( + await ref.read(strategyProvider.notifier).moveToFolder( strategyID: item.strategyId, parentID: null, source: item.strategy == null @@ -373,7 +373,7 @@ class _SidebarRootItem extends ConsumerWidget { : StrategySource.local, ); } else if (item is FolderItem) { - ref.read(folderProvider.notifier).moveToFolder( + await ref.read(folderProvider.notifier).moveToFolder( folderID: item.folder.id, parentID: null, workspace: ref.read(libraryWorkspaceProvider), @@ -545,10 +545,10 @@ class _FolderSidebarItemState extends ConsumerState<_FolderSidebarItem> { } return true; }, - onAcceptWithDetails: (details) { + onAcceptWithDetails: (details) async { final item = details.data; if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( + await ref.read(strategyProvider.notifier).moveToFolder( strategyID: item.strategyId, parentID: folder.id, source: item.strategy == null @@ -556,7 +556,7 @@ class _FolderSidebarItemState extends ConsumerState<_FolderSidebarItem> { : StrategySource.local, ); } else if (item is FolderItem) { - ref.read(folderProvider.notifier).moveToFolder( + await ref.read(folderProvider.notifier).moveToFolder( folderID: item.folder.id, parentID: folder.id, workspace: ref.read(libraryWorkspaceProvider), @@ -708,21 +708,13 @@ class _FolderSidebarItemState extends ConsumerState<_FolderSidebarItem> { onPressed: !canManage ? null : () async { - final confirmed = await ConfirmAlertDialog.show( + await showShadDialog( context: context, - title: "Delete '${folder.name}'?", - content: - 'This also removes every strategy and subfolder inside it.', - confirmText: 'Delete', - isDestructive: true, + builder: (_) => DeleteFolderAlertDialog( + folder: folder, + workspace: ref.read(libraryWorkspaceProvider), + ), ); - if (!confirmed) { - return; - } - ref.read(folderProvider.notifier).deleteFolder( - folder.id, - workspace: ref.read(libraryWorkspaceProvider), - ); }, child: Text( 'Delete', diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart index 17d9c9e0..4c883731 100644 --- a/lib/widgets/folder_pill.dart +++ b/lib/widgets/folder_pill.dart @@ -9,7 +9,7 @@ import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/drag_tilt_feedback.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; @@ -202,7 +202,7 @@ class _FolderPillState extends ConsumerState } if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( + await ref.read(strategyProvider.notifier).moveToFolder( strategyID: item.strategyId, parentID: widget.folder.id, source: item.strategy == null @@ -210,7 +210,7 @@ class _FolderPillState extends ConsumerState : StrategySource.local, ); } else if (item is FolderItem) { - ref.read(folderProvider.notifier).moveToFolder( + await ref.read(folderProvider.notifier).moveToFolder( folderID: item.folder.id, parentID: widget.folder.id, workspace: ref.read(libraryWorkspaceProvider), @@ -442,23 +442,14 @@ class _FolderPillState extends ConsumerState ? null : () async { _closeMenus(); - ConfirmAlertDialog.show( + if (widget.isDemo) return; + await showShadDialog( context: context, - title: - "Are you sure you want to delete '${widget.folder.name}' folder?", - content: - "This will also delete all strategies and subfolders within it.", - confirmText: "Delete", - isDestructive: true, - ).then((confirmed) { - if (confirmed) { - if (widget.isDemo) return; - ref.read(folderProvider.notifier).deleteFolder( - widget.folder.id, - workspace: ref.read(libraryWorkspaceProvider), - ); - } - }); + builder: (_) => DeleteFolderAlertDialog( + folder: widget.folder, + workspace: ref.read(libraryWorkspaceProvider), + ), + ); }, ), ]; diff --git a/lib/widgets/global_shortcuts.dart b/lib/widgets/global_shortcuts.dart index 30dc7074..b23b4263 100644 --- a/lib/widgets/global_shortcuts.dart +++ b/lib/widgets/global_shortcuts.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/delete_menu_provider.dart'; import 'package:icarus/providers/duplicate_drag_modifier_provider.dart'; import 'package:icarus/providers/hovered_delete_target_provider.dart'; @@ -61,6 +62,8 @@ class _GlobalShortcutsState extends ConsumerState @override Widget build(BuildContext context) { + final capabilities = ref.watch(currentStrategyCapabilitiesProvider); + return Focus( autofocus: true, // canRequestFocus: true, @@ -102,6 +105,7 @@ class _GlobalShortcutsState extends ConsumerState ), AddPageIntent: CallbackAction( onInvoke: (intent) async { + if (!capabilities.canAddPage) return null; _dismissDeleteMenu(); await ref.read(strategyProvider.notifier).addPage(); return null; @@ -109,6 +113,7 @@ class _GlobalShortcutsState extends ConsumerState ), ToggleLineupIntent: CallbackAction( onInvoke: (intent) { + if (!capabilities.canEditPages) return null; _dismissDeleteMenu(); if (ref.read(interactionStateProvider) == InteractionState.lineUpPlacing) { @@ -132,6 +137,7 @@ class _GlobalShortcutsState extends ConsumerState ), ContextualDeleteIntent: CallbackAction( onInvoke: (intent) { + if (!capabilities.canEditPages) return null; final hoveredTarget = ref.read(hoveredDeleteTargetProvider); if (hoveredTarget != null) { _dismissDeleteMenu(); @@ -154,6 +160,7 @@ class _GlobalShortcutsState extends ConsumerState ), UndoActionIntent: CallbackAction( onInvoke: (intent) { + if (!capabilities.canEditPages) return null; _dismissDeleteMenu(); ref.read(actionProvider.notifier).undoAction(); return null; @@ -161,6 +168,7 @@ class _GlobalShortcutsState extends ConsumerState ), AddedTextIntent: CallbackAction( onInvoke: (intent) { + if (!capabilities.canEditPages) return null; _dismissDeleteMenu(); const uuid = Uuid(); final placementCenter = ref.read(placementCenterProvider); @@ -183,6 +191,7 @@ class _GlobalShortcutsState extends ConsumerState ), ToggleDrawingIntent: CallbackAction( onInvoke: (intent) { + if (!capabilities.canEditPages) return null; _dismissDeleteMenu(); if (ref.read(interactionStateProvider) == InteractionState.drawing) { @@ -199,6 +208,7 @@ class _GlobalShortcutsState extends ConsumerState ), ToggleErasingIntent: CallbackAction( onInvoke: (intent) async { + if (!capabilities.canEditPages) return null; _dismissDeleteMenu(); if (ref.read(interactionStateProvider) == InteractionState.erasing) { @@ -216,6 +226,7 @@ class _GlobalShortcutsState extends ConsumerState ), RedoActionIntent: CallbackAction( onInvoke: (intent) { + if (!capabilities.canEditPages) return null; _dismissDeleteMenu(); ref.read(actionProvider.notifier).redoAction(); return null; @@ -223,6 +234,7 @@ class _GlobalShortcutsState extends ConsumerState ), SaveStrategyIntent: CallbackAction( onInvoke: (intent) async { + if (!capabilities.canEditPages) return null; _dismissDeleteMenu(); final strategyId = ref.read(strategyProvider).strategyId; if (strategyId == null) return null; diff --git a/lib/widgets/image_drop_target.dart b/lib/widgets/image_drop_target.dart index f68aeeac..17ec4a60 100644 --- a/lib/widgets/image_drop_target.dart +++ b/lib/widgets/image_drop_target.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -19,18 +20,29 @@ class _ImageDropTargetState extends ConsumerState { @override Widget build(BuildContext context) { + final canEditPages = ref.watch( + currentStrategyCapabilitiesProvider.select( + (capabilities) => capabilities.canEditPages, + ), + ); + return DropTarget( onDragEntered: (details) { + if (!canEditPages) return; setState(() { isDragging = true; }); }, onDragExited: (details) { + if (!canEditPages) return; setState(() { isDragging = false; }); }, onDragDone: (details) async { + if (!ref.read(currentStrategyCapabilitiesProvider).canEditPages) { + return; + } if (kIsWeb) { Settings.showToast( message: 'This feature is only supported in the Windows version.', diff --git a/lib/widgets/save_and_load_button.dart b/lib/widgets/save_and_load_button.dart index 3f117688..56d00c40 100644 --- a/lib/widgets/save_and_load_button.dart +++ b/lib/widgets/save_and_load_button.dart @@ -8,13 +8,13 @@ import 'package:hive_ce/hive.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/collab/cloud_collab_provider.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -81,7 +81,7 @@ class _SaveAndLoadButtonState extends ConsumerState { final exporter = StrategyImportExportService(ref); switch (strategy.source!) { case StrategySource.cloud: - await exporter.exportCloudStrategy(strategyId); + await runCloudStrategyExport(ref, strategyId); case StrategySource.local: await exporter.exportFile(strategyId); } @@ -231,8 +231,7 @@ class _SaveAndLoadButtonState extends ConsumerState { bool _isViewOnly() { final source = ref.watch(strategyProvider.select((value) => value.source)); - if (source != StrategySource.cloud || - !ref.watch(isCloudCollabEnabledProvider)) { + if (source != StrategySource.cloud) { return false; } // Read the cached role rather than the raw snapshot so the chip does not diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index d436a4fe..dd36c40c 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -15,10 +15,12 @@ import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/widgets/account_avatar.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/map_theme_settings_section.dart'; import 'package:icarus/widgets/settings_scope_card.dart'; +import 'package:icarus/widgets/strategy_edit_boundary.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -110,10 +112,13 @@ class _SettingsTabState extends ConsumerState { child: SingleChildScrollView( controller: _scrollController, child: switch (_mode) { - _SettingsMode.strategy => _StrategySettingsSections( - key: const ValueKey('strategy-settings'), - sectionKeys: _sectionKeys, - strategySettings: strategySettings, + _SettingsMode.strategy => StrategyEditBoundary( + disabledOpacity: 0.55, + child: _StrategySettingsSections( + key: const ValueKey('strategy-settings'), + sectionKeys: _sectionKeys, + strategySettings: strategySettings, + ), ), _SettingsMode.global => _GlobalSettingsSections( key: const ValueKey('global-settings'), @@ -306,7 +311,7 @@ class _GlobalSettingsSections extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _AccountSettingsSection( + AccountSettingsSection( key: sectionKeys[_SettingsSection.globalAccount], ), const SizedBox(height: 20), @@ -1026,8 +1031,8 @@ class _ShortcutEmptySearch extends StatelessWidget { } } -class _AccountSettingsSection extends ConsumerWidget { - const _AccountSettingsSection({super.key}); +class AccountSettingsSection extends ConsumerWidget { + const AccountSettingsSection({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -1191,11 +1196,12 @@ class _SignedInAccountRow extends ConsumerWidget { const SizedBox(width: 8), ], ShadButton.outline( + key: const ValueKey('settings-sign-out'), size: ShadButtonSize.sm, onPressed: authState.isLoading ? null - : () { - ref.read(authProvider.notifier).signOut(); + : () async { + await ref.read(guardedSignOutRequestProvider)(context); }, child: const Text('Sign Out'), ), diff --git a/lib/widgets/strategy_edit_boundary.dart b/lib/widgets/strategy_edit_boundary.dart new file mode 100644 index 00000000..38730e99 --- /dev/null +++ b/lib/widgets/strategy_edit_boundary.dart @@ -0,0 +1,35 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; + +/// Disables controls that mutate the open strategy when it is view-only. +class StrategyEditBoundary extends ConsumerWidget { + const StrategyEditBoundary({ + super.key, + required this.child, + this.disabledOpacity = 1, + }); + + final Widget child; + final double disabledOpacity; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final canEdit = ref.watch( + currentStrategyCapabilitiesProvider.select( + (capabilities) => capabilities.canEditPages, + ), + ); + + return AbsorbPointer( + absorbing: !canEdit, + child: ExcludeFocus( + excluding: !canEdit, + child: Opacity( + opacity: canEdit ? 1 : disabledOpacity, + child: child, + ), + ), + ); + } +} diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 1f53ae4b..4d84cac0 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/auto_save_notifier.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -84,6 +85,11 @@ class _AutoSaveButtonState extends ConsumerState @override Widget build(BuildContext context) { final ping = ref.watch(autoSaveProvider); + final canEditPages = ref.watch( + currentStrategyCapabilitiesProvider.select( + (capabilities) => capabilities.canEditPages, + ), + ); if (ping != _lastPing) { _lastPing = ping; @@ -116,10 +122,11 @@ class _AutoSaveButtonState extends ConsumerState } return ShadTooltip( - builder: (context) => const Text("Save"), + builder: (context) => Text(canEditPages ? "Save" : "View only"), child: ShadIconButton.ghost( foregroundColor: Colors.white, icon: icon, + enabled: canEditPages, onPressed: () async { await ref .read(strategyProvider.notifier) diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 4b7992ad..f5ea5cd6 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -7,6 +7,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/library_context_menu_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -497,7 +498,7 @@ class _StrategyTileState extends ConsumerState { } if (_isCloud) { - await StrategyImportExportService(ref).exportCloudStrategy(_strategyId); + await runCloudStrategyExport(ref, _strategyId); return; } diff --git a/release/metadata/4.6.1+97.json b/release/metadata/4.6.1+97.json new file mode 100644 index 00000000..eee3b8ae --- /dev/null +++ b/release/metadata/4.6.1+97.json @@ -0,0 +1,29 @@ +{ + "version": "4.6.1+97", + "shortVersion": 97, + "title": "Icarus Online Beta", + "description": "Invite-only cloud strategy sync and sharing for internal testing.", + "date": "2026-09-03", + "mandatory": false, + "channels": [ + "desktop", + "prerelease" + ], + "platforms": [ + "windows" + ], + "changes": [ + { + "message": "Signed-in testers can keep a cloud library and sync strategy changes through the Icarus backend.", + "type": "feature" + }, + { + "message": "Strategies can be shared with view-only or editor access during the online beta.", + "type": "feature" + }, + { + "message": "This build is an internal prerelease. Cloud rollback and the complete two-client release check are not finished yet.", + "type": "other" + } + ] +} diff --git a/scripts/assert_release_preflight.ps1 b/scripts/assert_release_preflight.ps1 new file mode 100644 index 00000000..d16699be --- /dev/null +++ b/scripts/assert_release_preflight.ps1 @@ -0,0 +1,30 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet("stable-desktop", "prerelease-desktop", "store", "production-backend")] + [string]$ReleaseTarget, + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, + [string]$BranchName = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common_release.ps1") + +$repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget $ReleaseTarget -BranchName $BranchName | Out-Null + +$cloudReleaseTarget = switch ($ReleaseTarget) { + "stable-desktop" { "stable" } + "prerelease-desktop" { "prerelease" } + "store" { "store" } + default { $null } +} + +if ($null -ne $cloudReleaseTarget) { + Resolve-CloudBuildConfiguration ` + -ReleaseTarget $cloudReleaseTarget ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId | Out-Null +} diff --git a/scripts/build_desktop_release.ps1 b/scripts/build_desktop_release.ps1 index a9285b20..dacccf9f 100644 --- a/scripts/build_desktop_release.ps1 +++ b/scripts/build_desktop_release.ps1 @@ -10,6 +10,8 @@ param( [string]$InitialChangeMessage = "Describe this release before publishing.", [string]$PostHogProjectToken = $env:POSTHOG_PROJECT_TOKEN, [string]$PostHogHost = $(if ($env:POSTHOG_HOST) { $env:POSTHOG_HOST } else { "https://us.i.posthog.com" }), + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, [switch]$SkipPubGet ) @@ -19,6 +21,12 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +$releaseTarget = if ($Channel -eq "stable") { "stable-desktop" } else { "prerelease-desktop" } +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget $releaseTarget | Out-Null +$cloudBuildConfiguration = Resolve-CloudBuildConfiguration ` + -ReleaseTarget $Channel ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId $env:FLUTTER_ROOT = Get-FlutterRoot -RepoRoot $repoRoot if (-not $SkipPubGet) { @@ -35,14 +43,21 @@ try { "--release", "--dart-define=ICARUS_UPDATE_CHANNEL=$Channel" ) + $dartDefines = [ordered]@{ + ICARUS_CLOUD_ENVIRONMENT = $cloudBuildConfiguration.Environment + } + if ($cloudBuildConfiguration.Environment -eq "production") { + $dartDefines.ICARUS_CONVEX_DEPLOYMENT_URL = $cloudBuildConfiguration.DeploymentUrl + $dartDefines.ICARUS_CONVEX_CLIENT_ID = $cloudBuildConfiguration.ClientId + } if (-not [string]::IsNullOrWhiteSpace($PostHogProjectToken)) { - $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) - Write-JsonFileUtf8 -Path $dartDefinesPath -Value @{ - POSTHOG_PROJECT_TOKEN = $PostHogProjectToken - POSTHOG_HOST = $PostHogHost - } - $releaseArguments += "--dart-define-from-file=$dartDefinesPath" + $dartDefines.POSTHOG_PROJECT_TOKEN = $PostHogProjectToken + $dartDefines.POSTHOG_HOST = $PostHogHost } + + $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) + Write-JsonFileUtf8 -Path $dartDefinesPath -Value $dartDefines + $releaseArguments += "--dart-define-from-file=$dartDefinesPath" Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments $releaseArguments } finally { @@ -146,9 +161,7 @@ else { $missingChannels = @($requiredChannels | Where-Object { $channels -notcontains $_ }) if ($missingChannels.Count -gt 0) { - $metadata.channels = @($channels + $missingChannels) - Write-JsonFileUtf8 -Value $metadata -Path $metadataPath -Depth 6 - Write-Host ("Updated release metadata channels at {0}: {1}" -f $metadataPath, ($metadata.channels -join ", ")) + throw "Release metadata '$metadataPath' does not include channel(s): $($missingChannels -join ', '). Review and edit the metadata explicitly before building." } } diff --git a/scripts/build_store_release.ps1 b/scripts/build_store_release.ps1 index b5aeefe9..534d64e0 100644 --- a/scripts/build_store_release.ps1 +++ b/scripts/build_store_release.ps1 @@ -2,6 +2,8 @@ param( [string]$OutputDir = "release\out\store", [string]$PostHogProjectToken = $env:POSTHOG_PROJECT_TOKEN, [string]$PostHogHost = $(if ($env:POSTHOG_HOST) { $env:POSTHOG_HOST } else { "https://us.i.posthog.com" }), + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, [switch]$SkipPubGet ) @@ -11,6 +13,11 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "store" | Out-Null +$cloudBuildConfiguration = Resolve-CloudBuildConfiguration ` + -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId $env:FLUTTER_ROOT = Get-FlutterRoot -RepoRoot $repoRoot $windowsBuildRoot = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath "build\windows" @@ -25,14 +32,18 @@ try { } $flutterBuildArguments = @("flutter", "build", "windows", "--release") + $dartDefines = [ordered]@{ + ICARUS_CLOUD_ENVIRONMENT = $cloudBuildConfiguration.Environment + ICARUS_CONVEX_DEPLOYMENT_URL = $cloudBuildConfiguration.DeploymentUrl + ICARUS_CONVEX_CLIENT_ID = $cloudBuildConfiguration.ClientId + } if (-not [string]::IsNullOrWhiteSpace($PostHogProjectToken)) { - $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) - Write-JsonFileUtf8 -Path $dartDefinesPath -Value @{ - POSTHOG_PROJECT_TOKEN = $PostHogProjectToken - POSTHOG_HOST = $PostHogHost - } - $flutterBuildArguments += "--dart-define-from-file=$dartDefinesPath" + $dartDefines.POSTHOG_PROJECT_TOKEN = $PostHogProjectToken + $dartDefines.POSTHOG_HOST = $PostHogHost } + $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) + Write-JsonFileUtf8 -Path $dartDefinesPath -Value $dartDefines + $flutterBuildArguments += "--dart-define-from-file=$dartDefinesPath" Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments $flutterBuildArguments # Stage the video-export encoder into the build output before packaging. Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "powershell" -Arguments @( diff --git a/scripts/common_release.ps1 b/scripts/common_release.ps1 index 7c309c1f..b1303674 100644 --- a/scripts/common_release.ps1 +++ b/scripts/common_release.ps1 @@ -48,6 +48,173 @@ function Get-VersionInfo { } } +function Get-ReleaseBranchName { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [string]$BranchName = "" + ) + + if (-not [string]::IsNullOrWhiteSpace($BranchName)) { + return $BranchName.Trim() + } + + if ($env:GITHUB_REF_TYPE -eq "branch" -and -not [string]::IsNullOrWhiteSpace($env:GITHUB_REF_NAME)) { + return $env:GITHUB_REF_NAME.Trim() + } + + $resolvedBranch = (& git -C $RepoRoot branch --show-current).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($resolvedBranch)) { + throw "Could not determine the current branch for release safety checks." + } + + return $resolvedBranch +} + +function Assert-ReleaseBranch { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [Parameter(Mandatory = $true)] + [ValidateSet("stable-desktop", "prerelease-desktop", "store", "production-backend")] + [string]$ReleaseTarget, + [string]$BranchName = "" + ) + + $resolvedBranch = Get-ReleaseBranchName -RepoRoot $RepoRoot -BranchName $BranchName + if ($ReleaseTarget -ne "prerelease-desktop" -and $resolvedBranch -ne "main") { + throw "Release target '$ReleaseTarget' is public and can only run from branch 'main'. Current branch: '$resolvedBranch'." + } + + Write-Host "Release branch check passed for '$ReleaseTarget' on '$resolvedBranch'." -ForegroundColor Green + return $resolvedBranch +} + +function Resolve-CloudBuildConfiguration { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("stable", "prerelease", "store")] + [string]$ReleaseTarget, + [string]$ProductionConvexDeploymentUrl = "", + [string]$ProductionConvexClientId = "" + ) + + if ($ReleaseTarget -eq "prerelease") { + return [ordered]@{ + Environment = "development" + DeploymentUrl = "" + ClientId = "" + } + } + + $deploymentUrl = $ProductionConvexDeploymentUrl.Trim() + $clientId = $ProductionConvexClientId.Trim() + if ([string]::IsNullOrWhiteSpace($deploymentUrl) -or [string]::IsNullOrWhiteSpace($clientId)) { + throw "Production cloud configuration is missing. Set ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL and ICARUS_PRODUCTION_CONVEX_CLIENT_ID before building '$ReleaseTarget'." + } + + $parsedUrl = $null + if (-not [System.Uri]::TryCreate($deploymentUrl, [System.UriKind]::Absolute, [ref]$parsedUrl) -or + $parsedUrl.Scheme -ne "https" -or + [string]::IsNullOrWhiteSpace($parsedUrl.Host)) { + throw "ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL must be an absolute HTTPS URL." + } + + $hasCanonicalConvexOrigin = $deploymentUrl -match '^https://[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.convex\.cloud/?$' + if (-not $hasCanonicalConvexOrigin) { + throw "ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL must be a canonical https://.convex.cloud URL." + } + + if ($parsedUrl.Host -ieq "majestic-eel-413.convex.cloud" -or $clientId -eq "dev:majestic-eel-413") { + throw "Production cloud configuration cannot use the Icarus development Convex deployment." + } + + return [ordered]@{ + Environment = "production" + DeploymentUrl = $deploymentUrl + ClientId = $clientId + } +} + +function Test-PublishesStablePages { + param( + [Parameter(Mandatory = $true)] + [string]$SourceDirectory, + [Parameter()] + [string[]]$SyncPaths = @() + ) + + if ($SyncPaths.Count -gt 0) { + $stableRoots = @( + (Resolve-PagesSyncPath -RootDirectory $SourceDirectory -SyncPath "updates/windows/stable"), + (Resolve-PagesSyncPath -RootDirectory $SourceDirectory -SyncPath "downloads/windows/stable") + ) + + foreach ($syncPath in $SyncPaths) { + $selectedPath = Resolve-PagesSyncPath -RootDirectory $SourceDirectory -SyncPath $syncPath + foreach ($stableRoot in $stableRoots) { + if ((Test-ReleasePathsOverlap -FirstPath $selectedPath -SecondPath $stableRoot)) { + return $true + } + } + } + return $false + } + + $stableRoots = @( + (Join-Path $SourceDirectory "updates\windows\stable"), + (Join-Path $SourceDirectory "downloads\windows\stable") + ) + return @($stableRoots | Where-Object { Test-Path -LiteralPath $_ }).Count -gt 0 +} + +function Resolve-PagesSyncPath { + param( + [Parameter(Mandatory = $true)] + [string]$RootDirectory, + [Parameter(Mandatory = $true)] + [string]$SyncPath + ) + + if ([string]::IsNullOrWhiteSpace($SyncPath)) { + throw "Pages sync paths cannot be empty. Omit SyncPaths to publish the full source directory." + } + if ([System.IO.Path]::IsPathRooted($SyncPath)) { + throw "Pages sync path '$SyncPath' must be relative to the Pages source directory." + } + + $rootPath = [System.IO.Path]::GetFullPath($RootDirectory) + $resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $rootPath $SyncPath)) + $separator = [System.IO.Path]::DirectorySeparatorChar.ToString() + $rootPrefix = if ($rootPath.EndsWith($separator)) { $rootPath } else { "$rootPath$separator" } + $isRoot = [string]::Equals($resolvedPath, $rootPath, [System.StringComparison]::OrdinalIgnoreCase) + $isChild = $resolvedPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase) + if (-not $isRoot -and -not $isChild) { + throw "Pages sync path '$SyncPath' must stay within the Pages source directory." + } + + return $resolvedPath +} + +function Test-ReleasePathsOverlap { + param( + [Parameter(Mandatory = $true)] + [string]$FirstPath, + [Parameter(Mandatory = $true)] + [string]$SecondPath + ) + + $first = [System.IO.Path]::GetFullPath($FirstPath) + $second = [System.IO.Path]::GetFullPath($SecondPath) + $separator = [System.IO.Path]::DirectorySeparatorChar.ToString() + $firstPrefix = if ($first.EndsWith($separator)) { $first } else { "$first$separator" } + $secondPrefix = if ($second.EndsWith($separator)) { $second } else { "$second$separator" } + + return [string]::Equals($first, $second, [System.StringComparison]::OrdinalIgnoreCase) -or + $first.StartsWith($secondPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or + $second.StartsWith($firstPrefix, [System.StringComparison]::OrdinalIgnoreCase) +} + function Get-FlutterRoot { param( [Parameter(Mandatory = $true)] diff --git a/scripts/publish_pages_branch.ps1 b/scripts/publish_pages_branch.ps1 index 2f2ee184..e67858af 100644 --- a/scripts/publish_pages_branch.ps1 +++ b/scripts/publish_pages_branch.ps1 @@ -12,6 +12,10 @@ Set-StrictMode -Version Latest $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot $resolvedSourceDir = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath $SourceDir +$publishesStable = Test-PublishesStablePages -SourceDirectory $resolvedSourceDir -SyncPaths $SyncPaths +if ($publishesStable) { + Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "stable-desktop" | Out-Null +} if (-not (Test-Path $resolvedSourceDir)) { throw "Pages source directory not found at $resolvedSourceDir" @@ -67,19 +71,28 @@ try { if ($SyncPaths.Count -gt 0) { foreach ($syncPath in $SyncPaths) { - $sourcePath = Join-Path $resolvedSourceDir $syncPath - $targetPath = Join-Path $tempRoot $syncPath + $sourcePath = Resolve-PagesSyncPath -RootDirectory $resolvedSourceDir -SyncPath $syncPath + $targetPath = Resolve-PagesSyncPath -RootDirectory $tempRoot -SyncPath $syncPath + + if ([string]::Equals( + $targetPath, + [System.IO.Path]::GetFullPath($tempRoot), + [System.StringComparison]::OrdinalIgnoreCase + )) { + Copy-Item -Path (Join-Path $resolvedSourceDir "*") -Destination $tempRoot -Recurse -Force + continue + } - if (Test-Path $targetPath) { - Remove-Item -Path $targetPath -Recurse -Force + if (Test-Path -LiteralPath $targetPath) { + Remove-Item -LiteralPath $targetPath -Recurse -Force } - if (Test-Path $sourcePath) { + if (Test-Path -LiteralPath $sourcePath) { $targetParent = Split-Path -Parent $targetPath if (-not [string]::IsNullOrWhiteSpace($targetParent)) { New-Item -ItemType Directory -Force -Path $targetParent | Out-Null } - Copy-Item -Path $sourcePath -Destination $targetPath -Recurse -Force + Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Recurse -Force } } } diff --git a/scripts/release_desktop.ps1 b/scripts/release_desktop.ps1 index 7a7e4e97..f4a85d10 100644 --- a/scripts/release_desktop.ps1 +++ b/scripts/release_desktop.ps1 @@ -14,6 +14,8 @@ param( [string]$PagesStageRoot = "release\out\gh-pages", [string]$MetadataDir = "release\metadata", [string]$AppArchiveBaseUrl = "", + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, [switch]$SkipPubGet ) @@ -23,6 +25,12 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +$releaseTarget = if ($Channel -eq "stable") { "stable-desktop" } else { "prerelease-desktop" } +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget $releaseTarget | Out-Null +Resolve-CloudBuildConfiguration ` + -ReleaseTarget $Channel ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId | Out-Null if ([string]::IsNullOrWhiteSpace($AppArchiveBaseUrl)) { $AppArchiveBaseUrl = "https://sunkenintime.github.io/icarus/updates/windows/$Channel" @@ -53,7 +61,11 @@ $buildArgs = @( "-AppArchiveBaseUrl", $AppArchiveBaseUrl, "-InitialChangeMessage", - $ChangeMessage + $ChangeMessage, + "-ProductionConvexDeploymentUrl", + $ProductionConvexDeploymentUrl, + "-ProductionConvexClientId", + $ProductionConvexClientId ) if ($Mandatory) { diff --git a/scripts/test_release_safety.ps1 b/scripts/test_release_safety.ps1 new file mode 100644 index 00000000..db013f91 --- /dev/null +++ b/scripts/test_release_safety.ps1 @@ -0,0 +1,192 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common_release.ps1") + +$repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot + +function Assert-ThrowsContaining { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Action, + [Parameter(Mandatory = $true)] + [string]$ExpectedMessage + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notlike "*$ExpectedMessage*") { + throw "Expected an error containing '$ExpectedMessage', got: $($_.Exception.Message)" + } + return + } + + throw "Expected an error containing '$ExpectedMessage', but the action succeeded." +} + +function Assert-TextAppearsBefore { + param( + [Parameter(Mandatory = $true)] + [string]$Text, + [Parameter(Mandatory = $true)] + [string]$First, + [Parameter(Mandatory = $true)] + [string]$Second + ) + + $firstIndex = $Text.IndexOf($First) + $secondIndex = $Text.IndexOf($Second) + if ($firstIndex -lt 0 -or $secondIndex -lt 0 -or $firstIndex -gt $secondIndex) { + throw "Expected '$First' to appear before '$Second'." + } +} + +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "stable-desktop" -BranchName "main" | Out-Null +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "store" -BranchName "main" | Out-Null +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "production-backend" -BranchName "main" | Out-Null +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "prerelease-desktop" -BranchName "icarus-cloud" | Out-Null +Assert-ThrowsContaining -ExpectedMessage "only run from branch 'main'" -Action { + Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "stable-desktop" -BranchName "icarus-cloud" +} +Assert-ThrowsContaining -ExpectedMessage "only run from branch 'main'" -Action { + Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "store" -BranchName "feature/cloud" +} + +if (-not (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths "updates/windows/stable")) { + throw "An explicit stable updater path must require the stable branch guard." +} +foreach ($stableSelection in @( + ".", + "updates/windows", + "downloads/windows", + "updates/windows/stable/4.6.1+97", + "updates/windows/prerelease/../stable" +)) { + if (-not (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths $stableSelection)) { + throw "Pages selection '$stableSelection' must require the stable branch guard." + } +} +foreach ($prereleaseSelection in @( + "updates/windows/prerelease", + "downloads/windows/prerelease" +)) { + if (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths $prereleaseSelection) { + throw "Prerelease-only Pages selection '$prereleaseSelection' must remain available on feature branches." + } +} +Assert-ThrowsContaining -ExpectedMessage "must stay within the Pages source directory" -Action { + Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths ".." +} +Assert-ThrowsContaining -ExpectedMessage "must be relative to the Pages source directory" -Action { + Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths $repoRoot +} + +$pagesFixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-release-safety-" + [guid]::NewGuid().ToString("N")) +try { + New-Item -ItemType Directory -Force -Path (Join-Path $pagesFixtureRoot "downloads\windows\stable") | Out-Null + if (-not (Test-PublishesStablePages -SourceDirectory $pagesFixtureRoot)) { + throw "A full-directory publish containing stable downloads must require the stable branch guard." + } +} +finally { + if (Test-Path -LiteralPath $pagesFixtureRoot) { + Remove-Item -LiteralPath $pagesFixtureRoot -Recurse -Force + } +} + +$prereleaseConfig = Resolve-CloudBuildConfiguration -ReleaseTarget "prerelease" +if ($prereleaseConfig.Environment -ne "development") { + throw "Prerelease builds must select the development cloud environment." +} +if (-not [string]::IsNullOrWhiteSpace($prereleaseConfig.DeploymentUrl) -or + -not [string]::IsNullOrWhiteSpace($prereleaseConfig.ClientId)) { + throw "Prerelease builds must use the app's named development defaults." +} + +Assert-ThrowsContaining -ExpectedMessage "Production cloud configuration is missing" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" +} +Assert-ThrowsContaining -ExpectedMessage "Production cloud configuration is missing" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud" +} +Assert-ThrowsContaining -ExpectedMessage "absolute HTTPS URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https:production-example" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "canonical https://.convex.cloud URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://production-example.invalid" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "canonical https://.convex.cloud URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.site" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "canonical https://.convex.cloud URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud/api" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "development Convex deployment" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://majestic-eel-413.convex.cloud/" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "development Convex deployment" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud" ` + -ProductionConvexClientId "dev:majestic-eel-413" +} + +$productionConfig = Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud" ` + -ProductionConvexClientId "icarus-production" +if ($productionConfig.Environment -ne "production" -or + $productionConfig.DeploymentUrl -ne "https://production-example.convex.cloud" -or + $productionConfig.ClientId -ne "icarus-production") { + throw "A complete production cloud configuration was not preserved." +} + +$desktopWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\release-desktop.yml") -Raw +$storeWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\release-store.yml") -Raw +$productionWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\deploy-convex-production.yml") -Raw + +Assert-TextAppearsBefore -Text $desktopWorkflow -First "Run Release Preflight" -Second "Install FVM" +if ($desktopWorkflow -notmatch 'production-approval:[\s\S]*environment:\s*Production' -or + $desktopWorkflow -notmatch 'needs:\s*production-approval') { + throw "Stable desktop publishing must pass the GitHub Production environment before the build job." +} +Assert-TextAppearsBefore -Text $storeWorkflow -First "Run Store Release Preflight" -Second "Bump Version" +if ($productionWorkflow -notmatch 'environment:\s*Production') { + throw "The production Convex deployment job must use the GitHub Production environment." +} +if ($productionWorkflow -notmatch 'secrets\.CONVEX_PRODUCTION_DEPLOY_KEY') { + throw "The production Convex deployment must read CONVEX_PRODUCTION_DEPLOY_KEY." +} +if ($productionWorkflow -match 'CONVEX_PREVIEW_DEPLOY_KEY') { + throw "The production Convex deployment must never reference the preview deploy key." +} +if ($productionWorkflow -notmatch 'CONFIRMATION:\s*\$\{\{\s*inputs\.confirmation\s*\}\}' -or + $productionWorkflow -notmatch 'if \[\[ "\$CONFIRMATION" != "deploy-production" \]\]') { + throw "The production confirmation must enter Bash through the environment and remain data." +} +if ($productionWorkflow -match 'if \[\[ "\$\{\{\s*inputs\.confirmation') { + throw "The production confirmation must never be interpolated directly into Bash source." +} +Assert-TextAppearsBefore -Text $productionWorkflow -First "Check Convex Types" -Second "Deploy Convex Production Backend" +Assert-TextAppearsBefore -Text $productionWorkflow -First "Run Convex Tests" -Second "Deploy Convex Production Backend" + +$currentMetadata = Get-Content (Join-Path $repoRoot "release\metadata\4.6.1+97.json") -Raw | ConvertFrom-Json +if (@($currentMetadata.channels) -contains "stable") { + throw "The current online-beta metadata must not claim the stable channel." +} +if (@($currentMetadata.channels) -notcontains "prerelease") { + throw "The current online-beta metadata must include the prerelease channel." +} + +Write-Host "Release safety checks passed." -ForegroundColor Green diff --git a/test/cloud_build_config_test.dart b/test/cloud_build_config_test.dart new file mode 100644 index 00000000..d699c9b3 --- /dev/null +++ b/test/cloud_build_config_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/config/cloud_build_config.dart'; + +void main() { + group('CloudBuildConfig', () { + test('debug builds default to the development environment', () { + final config = CloudBuildConfig.forBuild(isReleaseMode: false); + + expect(config.environment, 'development'); + expect(config.deploymentUrl, developmentConvexDeploymentUrl); + }); + + test('release builds require an intentional environment', () { + expect( + () => CloudBuildConfig.forBuild(isReleaseMode: true), + throwsStateError, + ); + }); + + test('release builds may intentionally select development', () { + final config = CloudBuildConfig.forBuild( + isReleaseMode: true, + environment: 'development', + ); + + expect(config.environment, 'development'); + expect(config.deploymentUrl, developmentConvexDeploymentUrl); + }); + + test('development uses the named development deployment by default', () { + final config = CloudBuildConfig.resolve(environment: 'development'); + + expect(config.environment, 'development'); + expect(config.deploymentUrl, developmentConvexDeploymentUrl); + expect(config.clientId, developmentConvexClientId); + }); + + test('development overrides must be supplied as a pair', () { + expect( + () => CloudBuildConfig.resolve( + environment: 'development', + deploymentUrl: 'https://custom-dev.convex.cloud', + ), + throwsStateError, + ); + }); + + test('production requires an explicit deployment URL and client ID', () { + expect( + () => CloudBuildConfig.resolve(environment: 'production'), + throwsStateError, + ); + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: 'https://production-example.convex.cloud', + ), + throwsStateError, + ); + }); + + test('production rejects the development deployment', () { + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: '$developmentConvexDeploymentUrl/', + clientId: 'icarus-production', + ), + throwsStateError, + ); + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: 'https://production-example.convex.cloud', + clientId: developmentConvexClientId, + ), + throwsStateError, + ); + }); + + test('production requires a canonical Convex deployment URL', () { + for (final invalidUrl in [ + 'https://production-example.invalid', + 'https://production-example.convex.site', + 'https://production-example.convex.cloud.example.com', + 'https://production-example.convex.cloud/api', + ]) { + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: invalidUrl, + clientId: 'icarus-production', + ), + throwsStateError, + reason: invalidUrl, + ); + } + }); + + test('production accepts a complete non-development configuration', () { + final config = CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: 'https://production-example.convex.cloud', + clientId: 'icarus-production', + ); + + expect(config.environment, 'production'); + expect(config.deploymentUrl, 'https://production-example.convex.cloud'); + expect(config.clientId, 'icarus-production'); + }); + + test('unknown environments fail closed', () { + expect( + () => CloudBuildConfig.resolve(environment: 'staging'), + throwsStateError, + ); + }); + }); +} diff --git a/test/cloud_media_upload_queue_provider_test.dart b/test/cloud_media_upload_queue_provider_test.dart new file mode 100644 index 00000000..335afa10 --- /dev/null +++ b/test/cloud_media_upload_queue_provider_test.dart @@ -0,0 +1,740 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class _SignedOutAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, + ); +} + +class _CloudReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +class _DisabledCloudCollabMode extends CloudCollabModeNotifier { + @override + CloudCollabModeState build() => const CloudCollabModeState( + featureFlagEnabled: false, + forceLocalFallback: false, + ); +} + +class _EnabledCloudCollabMode extends CloudCollabModeNotifier { + @override + CloudCollabModeState build() => const CloudCollabModeState( + featureFlagEnabled: true, + forceLocalFallback: false, + ); +} + +class _ActiveCloudStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: 'strategy-a', + strategyName: 'Strategy A', + source: StrategySource.cloud, + isOpen: true, + ); +} + +class _NoOpenStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState(); +} + +class _SettledOpQueue extends StrategyOpQueueNotifier { + @override + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + clientId: 'client-a', + durableLoaded: true, + ); +} + +class _MutableMediaQueue extends CloudMediaUploadQueueNotifier { + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); + + void replaceJobs(List jobs) { + state = CloudMediaUploadQueueState(jobs: jobs, isProcessing: false); + } +} + +class _FixedOpQueue extends StrategyOpQueueNotifier { + _FixedOpQueue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; +} + +class _FailingBatchStore extends MemoryDurableCloudMediaOutboxStore { + bool failBatch = false; + + @override + Future putAll(Iterable jobs) async { + if (failBatch) { + throw StateError('batch write failed'); + } + await super.putAll(jobs); + } +} + +ProviderContainer _container( + MemoryDurableCloudMediaOutboxStore store, { + MemoryDurableStrategyOutboxStore? strategyStore, + StrategyOpQueueState? opQueueState, + bool strategyOpen = true, + bool cloudReady = false, + bool cloudEnabled = false, + String? accountId = 'account-a', + CloudMediaReferenceSnapshotLoader? referenceSnapshotLoader, +}) { + final resolvedOpQueueState = opQueueState ?? + (strategyOpen + ? const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + clientId: 'client-a', + durableLoaded: true, + ) + : const StrategyOpQueueState(durableLoaded: true)); + return ProviderContainer( + overrides: [ + durableCloudMediaOutboxStoreProvider.overrideWithValue(store), + durableStrategyOutboxStoreProvider.overrideWithValue( + strategyStore ?? MemoryDurableStrategyOutboxStore(), + ), + authProvider.overrideWith( + cloudReady ? _CloudReadyAuthProvider.new : _SignedOutAuthProvider.new, + ), + cloudMediaAccountIdProvider.overrideWithValue(accountId), + cloudCollabModeProvider.overrideWith( + cloudEnabled + ? _EnabledCloudCollabMode.new + : _DisabledCloudCollabMode.new, + ), + convexConnectionSnapshotProvider.overrideWithValue(cloudReady), + convexConnectionProvider.overrideWith( + (ref) => Stream.value(cloudReady), + ), + strategyProvider.overrideWith( + strategyOpen ? _ActiveCloudStrategy.new : _NoOpenStrategy.new, + ), + strategyOpQueueProvider.overrideWith( + () => _FixedOpQueue(resolvedOpQueueState), + ), + if (referenceSnapshotLoader != null) + cloudMediaReferenceSnapshotLoaderProvider.overrideWithValue( + referenceSnapshotLoader, + ), + ], + ); +} + +DurableOutboxRecord _durableRecord(StrategyOp op) { + final entityKey = EntitySyncKey.forStrategyOp(op)!; + return DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + entityKey: entityKey, + pending: PendingOp(op: op, clientId: 'client-a'), + status: DurableOutboxStatus.queued, + createdAt: DateTime.utc(2026, 9, 3), + updatedAt: DateTime.utc(2026, 9, 3), + ); +} + +RemoteFullStrategySnapshot _fullSnapshot({ + List elements = const [], + List lineups = const [], +}) { + final now = DateTime.utc(2026, 9, 3); + return RemoteFullStrategySnapshot( + header: RemoteStrategyHeader( + publicId: 'strategy-a', + name: 'Strategy A', + mapData: 'ascent', + revision: 1, + createdAt: now, + updatedAt: now, + ), + pages: const [], + elementsByPage: {'page-a': elements}, + lineupsByPage: {'page-a': lineups}, + assetsById: const {}, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('restart restores unfinished lineup media from the durable outbox', + () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final first = _container(store); + + await first + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: [ + SimpleImageData(id: 'lineup-image', fileExtension: '.png'), + ], + ); + first.dispose(); + + final restarted = _container(store); + addTearDown(restarted.dispose); + final state = restarted.read(cloudMediaUploadQueueProvider); + + expect(state.outboxIsReliable, isTrue); + expect(state.jobs, hasLength(1)); + expect(state.jobs.single.assetPublicId, 'lineup-image'); + expect(state.jobs.single.strategyPublicId, 'strategy-a'); + expect(state.jobs.single.fileExtension, '.png'); + expect(state.jobs.single.referenceDurable, isFalse); + }); + + test('account B cannot restore or reconcile account A media', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final accountA = _container(store, accountId: 'account-a'); + await accountA + .read(cloudMediaUploadQueueProvider.notifier) + .enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'account-a-image', + fileExtension: '.png', + ); + accountA.dispose(); + + var snapshotReads = 0; + final accountB = _container( + store, + accountId: 'account-b', + cloudReady: true, + referenceSnapshotLoader: (_) async { + snapshotReads += 1; + return _fullSnapshot(); + }, + ); + addTearDown(accountB.dispose); + final queue = accountB.read(cloudMediaUploadQueueProvider.notifier); + + await queue.retryNow(ignoreBackoff: true); + + expect(accountB.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + expect(snapshotReads, 0); + final preserved = store.load().jobs.single; + expect(preserved.accountId, 'account-a'); + expect(preserved.assetPublicId, 'account-a-image'); + expect(preserved.referenceDurable, isFalse); + }); + + test('enqueue fails before writing when no account owns the job', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final container = _container(store, accountId: null); + addTearDown(container.dispose); + + await expectLater( + container + .read(cloudMediaUploadQueueProvider.notifier) + .enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'unowned-image', + fileExtension: '.png', + ), + throwsA(isA()), + ); + + expect(store.values, isEmpty); + }); + + test('account-scoped keys isolate identical asset IDs and removal', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + CloudMediaUploadJob job(String accountId) => CloudMediaUploadJob( + jobId: 'shared-image', + accountId: accountId, + strategyPublicId: 'strategy-a', + assetPublicId: 'shared-image', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ); + final accountAJob = job('account-a'); + final accountBJob = job('account-b'); + + await store.putAll([accountAJob, accountBJob]); + expect( + store.values.keys, + containsAll(['account-a|shared-image', 'account-b|shared-image']), + ); + + await store.remove(accountAJob); + + final remaining = store.load().jobs.single; + expect(remaining.accountId, 'account-b'); + expect(remaining.assetPublicId, 'shared-image'); + }); + + test('restart keeps an interrupted placement staged and non-runnable', + () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final first = _container(store); + + await first + .read(cloudMediaUploadQueueProvider.notifier) + .enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'interrupted-image', + fileExtension: '.png', + ); + first.dispose(); + + final restarted = _container(store); + addTearDown(restarted.dispose); + await Future.delayed(const Duration(milliseconds: 20)); + final job = restarted.read(cloudMediaUploadQueueProvider).jobs.single; + + expect(job.assetPublicId, 'interrupted-image'); + expect(job.referenceDurable, isFalse); + expect(job.attempts, 0); + expect(restarted.read(cloudMediaUploadQueueProvider).isProcessing, isFalse); + }); + + test('restart restores an image job without visiting its page', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final first = _container(store); + + await first + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueJobForLocalFile( + strategyPublicId: 'strategy-a', + assetPublicId: 'unvisited-page-image', + fileExtension: '.webp', + width: 640, + height: 360, + ); + await Future.delayed(const Duration(milliseconds: 20)); + first.dispose(); + + final restarted = _container(store); + addTearDown(restarted.dispose); + final restored = restarted + .read(cloudMediaUploadQueueProvider) + .jobsForStrategy('strategy-a'); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(restored, hasLength(1)); + expect(restored.single.assetPublicId, 'unvisited-page-image'); + expect(restored.single.width, 640); + expect(restored.single.height, 360); + expect(restored.single.referenceDurable, isFalse); + }); + + test('local-file job waits for a durable strategy reference', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final container = _container( + store, + strategyStore: MemoryDurableStrategyOutboxStore(), + cloudReady: true, + cloudEnabled: true, + referenceSnapshotLoader: (_) async => _fullSnapshot(), + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueJobForLocalFile( + strategyPublicId: 'strategy-a', + assetPublicId: 'not-admitted-image', + fileExtension: '.png', + ); + await Future.delayed(const Duration(milliseconds: 30)); + + final job = container.read(cloudMediaUploadQueueProvider).jobs.single; + expect(job.referenceDurable, isFalse); + expect(job.attempts, 0); + expect(container.read(cloudMediaUploadQueueProvider).isProcessing, isFalse); + }); + + test('blocked uploads yield and keep their durable job pending', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final container = _container(store); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueJobForLocalFile( + strategyPublicId: 'strategy-a', + assetPublicId: 'offline-image', + fileExtension: '.jpg', + ); + await Future.delayed(const Duration(milliseconds: 20)); + + final state = container.read(cloudMediaUploadQueueProvider); + expect(state.isProcessing, isFalse); + expect(state.jobs.single.attempts, 0); + expect(store.values, contains('account-a|offline-image')); + }); + + test('lineup staging is atomic when the durable batch write fails', () async { + final store = _FailingBatchStore(); + final container = _container(store); + addTearDown(container.dispose); + await container + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueJobForLocalFile( + strategyPublicId: 'strategy-a', + assetPublicId: 'existing-image', + fileExtension: '.png', + ); + await Future.delayed(const Duration(milliseconds: 20)); + store.failBatch = true; + + await expectLater( + container + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: [ + SimpleImageData(id: 'lineup-image-1', fileExtension: '.png'), + SimpleImageData(id: 'lineup-image-2', fileExtension: '.png'), + ], + ), + throwsA(isA()), + ); + + expect(store.values.keys, ['account-a|existing-image']); + expect( + container + .read(cloudMediaUploadQueueProvider) + .jobs + .map((job) => job.assetPublicId), + ['existing-image'], + ); + }); + + test('restart promotes a staged lineup batch without opening its strategy', + () async { + const lineupOp = LineupAddOp( + opId: 'lineup-op', + lineupPublicId: 'lineup-a', + pagePublicId: 'page-a', + payload: { + 'images': [ + {'id': 'lineup-image-1'}, + {'id': 'lineup-image-2'}, + ], + }, + sortIndex: 0, + ); + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.putAll([ + for (final assetId in ['lineup-image-1', 'lineup-image-2']) + CloudMediaUploadJob( + jobId: assetId, + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: assetId, + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ]); + final strategyStore = MemoryDurableStrategyOutboxStore(); + await strategyStore.put(_durableRecord(lineupOp)); + final container = _container( + mediaStore, + strategyStore: strategyStore, + strategyOpen: false, + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + + expect(container.read(strategyProvider).isOpen, isFalse); + expect( + container + .read(cloudMediaUploadQueueProvider) + .jobs + .map((job) => job.referenceDurable), + everyElement(isTrue), + ); + }); + + test('restart recovers when the strategy op was acked before promotion', + () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put( + CloudMediaUploadJob( + jobId: 'acked-image', + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: 'acked-image', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ); + var snapshotReads = 0; + final snapshot = _fullSnapshot( + elements: [ + RemoteElement( + publicId: 'acked-image', + strategyPublicId: 'strategy-a', + pagePublicId: 'page-a', + elementType: 'image', + payload: cloudElementPayload( + kind: 'image', + data: const {'id': 'acked-image', 'elementType': 'image'}, + ), + sortIndex: 0, + revision: 1, + deleted: false, + ), + ], + ); + final container = _container( + mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + strategyOpen: false, + cloudReady: true, + referenceSnapshotLoader: (strategyId) async { + snapshotReads += 1; + expect(strategyId, 'strategy-a'); + return snapshot; + }, + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + + expect(snapshotReads, greaterThanOrEqualTo(1)); + expect(container.read(strategyProvider).isOpen, isFalse); + expect( + container + .read(cloudMediaUploadQueueProvider) + .jobs + .single + .referenceDurable, + isTrue, + ); + }); + + test('restart removes only an unreferenced staged job, not its source', + () async { + const assetId = 'orphan-image'; + final source = await PlacedImageProvider.getImageFile( + strategyID: 'strategy-a', + imageID: assetId, + fileExtension: '.png', + ); + await source.writeAsBytes([1, 2, 3]); + addTearDown(() async { + if (await source.exists()) await source.delete(); + }); + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put( + CloudMediaUploadJob( + jobId: assetId, + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: assetId, + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ); + final container = _container( + mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + strategyOpen: false, + cloudReady: true, + referenceSnapshotLoader: (_) async => _fullSnapshot(), + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + + expect(container.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + expect(mediaStore.values, isEmpty); + expect(await source.exists(), isTrue); + }); + + test('missing source job is removed after its reference is deleted', + () async { + final assetId = 'deleted-image-${DateTime.now().microsecondsSinceEpoch}'; + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put( + CloudMediaUploadJob( + jobId: assetId, + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: assetId, + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: true, + attempts: 3, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ); + final container = _container( + mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + cloudReady: true, + cloudEnabled: true, + referenceSnapshotLoader: (_) async => _fullSnapshot(), + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + await Future.delayed(const Duration(milliseconds: 30)); + + expect(container.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + expect(mediaStore.values, isEmpty); + }); + + test('a newly staged job is not pruned before its mutation is admitted', + () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + final container = _container( + mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + cloudReady: true, + referenceSnapshotLoader: (_) async => _fullSnapshot(), + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + + await queue.enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'new-image', + fileExtension: '.png', + ); + await queue.retryNow(ignoreBackoff: true); + + final job = container.read(cloudMediaUploadQueueProvider).jobs.single; + expect(job.assetPublicId, 'new-image'); + expect(job.referenceDurable, isFalse); + expect(mediaStore.values, contains('account-a|new-image')); + }); + + test('restart preserves an uploaded object that still needs attachment', + () async { + final store = MemoryDurableCloudMediaOutboxStore(); + await store.put( + CloudMediaUploadJob( + jobId: 'pending-attach-image', + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: 'pending-attach-image', + fileExtension: '.png', + mimeType: 'image/png', + provider: 'r2', + uploadId: 'upload-a', + objectKey: 'strategies/strategy-a/images/pending-attach-image.png', + etag: 'etag-a', + byteSize: 1024, + state: CloudMediaJobState.pendingAttach, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ); + + final restarted = _container(store); + addTearDown(restarted.dispose); + final job = restarted.read(cloudMediaUploadQueueProvider).jobs.single; + await Future.delayed(const Duration(milliseconds: 20)); + + expect(job.state, CloudMediaJobState.pendingAttach); + expect(job.uploadId, 'upload-a'); + expect(job.objectKey, contains('pending-attach-image.png')); + expect(job.etag, 'etag-a'); + }); + + test('media from another strategy does not affect the active save state', () { + final mediaQueue = _MutableMediaQueue(); + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_ActiveCloudStrategy.new), + strategyOpQueueProvider.overrideWith(_SettledOpQueue.new), + cloudMediaUploadQueueProvider.overrideWith(() => mediaQueue), + ], + ); + addTearDown(container.dispose); + container.read(strategySaveStateProvider); + + mediaQueue.replaceJobs([ + CloudMediaUploadJob( + jobId: 'other-image', + accountId: 'account-a', + strategyPublicId: 'strategy-b', + assetPublicId: 'other-image', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.failed, + attempts: 1, + lastError: 'Local media file is missing.', + updatedAt: DateTime.utc(2026, 9, 3), + ), + ]); + + final saveState = container.read(strategySaveStateProvider); + expect(saveState.hasPendingMediaSync, isFalse); + expect(saveState.mediaSyncErrorCount, 0); + expect(saveState.isDirty, isFalse); + }); +} diff --git a/test/cloud_sign_out_coordinator_test.dart b/test/cloud_sign_out_coordinator_test.dart new file mode 100644 index 00000000..601ad3e9 --- /dev/null +++ b/test/cloud_sign_out_coordinator_test.dart @@ -0,0 +1,431 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; +import 'package:icarus/services/cloud_sign_out_coordinator.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + testWidgets('cancel keeps durable inactive strategy and media work signed in', + (tester) async { + var rawSignOuts = 0; + var editorCloses = 0; + final container = _container( + opQueue: _pendingQueue(), + mediaQueue: _pendingMedia(), + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + closeEditor: () async => editorCloses += 1, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text('Cloud work is still waiting'), findsOneWidget); + expect( + find.textContaining('3 saved changes across 3 strategies'), + findsOneWidget, + ); + expect(find.textContaining('remains on this device'), findsOneWidget); + expect(find.textContaining('same account signs in again'), findsOneWidget); + expect(find.textContaining('Bind retake'), findsOneWidget); + expect(find.textContaining('Ascent execute'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(rawSignOuts, 0); + expect(editorCloses, 0); + }); + + testWidgets( + 'confirmed sign out closes the cloud editor without clearing work', + (tester) async { + var rawSignOuts = 0; + var editorCloses = 0; + final opQueue = _pendingQueue(); + final mediaQueue = _pendingMedia(); + final container = _container( + opQueue: opQueue, + mediaQueue: mediaQueue, + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + closeEditor: () async => editorCloses += 1, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign Out Anyway')); + await tester.pumpAndSettle(); + + expect(editorCloses, 1); + expect(rawSignOuts, 1); + expect(opQueue.accountOutbox.workCount, 2); + expect(mediaQueue.jobs, hasLength(1)); + }); + + testWidgets('failed local persistence blocks sign out on screen', + (tester) async { + var rawSignOuts = 0; + final container = _container( + preparation: () async => throw StateError('disk full'), + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text("Can't sign out yet"), findsOneWidget); + expect( + find.textContaining('could not confirm that all pending cloud work'), + findsOneWidget, + ); + expect(find.text('Sign Out Anyway'), findsNothing); + expect(rawSignOuts, 0); + }); + + testWidgets('preparation commits drafts and asks both outboxes to persist', + (tester) async { + final strategy = _PreparingCloudStrategy(); + final media = _PreparingMediaQueue(); + final container = ProviderContainer(overrides: [ + authProvider.overrideWith(_SignedInAuth.new), + strategyProvider.overrideWith(() => strategy), + strategySaveStateProvider.overrideWith(_CleanSaveState.new), + strategyOpQueueProvider.overrideWith( + () => _FixedOpQueue(const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'active-strategy', + durableLoaded: true, + )), + ), + cloudMediaUploadQueueProvider.overrideWith(() => media), + cloudEditorCloseProvider.overrideWithValue(() async {}), + rawSignOutProvider.overrideWithValue(() async => true), + cloudStrategyNamesProvider.overrideWithValue(const {}), + ]); + addTearDown(container.dispose); + container + .read(textDraftProvider.notifier) + .setDraft('draft-no-longer-mounted', 'last thought'); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + + expect(strategy.forceSaveCount, 1); + expect(media.retryCount, 1); + expect(container.read(textDraftProvider), isEmpty); + expect(find.text('Sign out?'), findsOneWidget); + }); + + testWidgets('an unreliable durable outbox blocks sign out', (tester) async { + var rawSignOuts = 0; + final container = _container( + opQueue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + hasDurabilityFailure: true, + ), + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text("Can't sign out yet"), findsOneWidget); + expect(rawSignOuts, 0); + }); + + testWidgets('normal sign out still asks for explicit confirmation', + (tester) async { + var rawSignOuts = 0; + final container = _container( + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text('Sign out?'), findsOneWidget); + await tester.tap(find.text('Sign Out')); + await tester.pumpAndSettle(); + expect(rawSignOuts, 1); + }); + + testWidgets('another account media is not shown or relabeled', + (tester) async { + final otherAccountMedia = CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'account-b-media', + accountId: 'account-b', + strategyPublicId: 'account-b-strategy', + assetPublicId: 'account-b-media', + fileExtension: 'png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: true, + attempts: 0, + updatedAt: DateTime.utc(2026), + ), + ], + isProcessing: false, + ); + final container = _container(mediaQueue: otherAccountMedia); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text('Sign out?'), findsOneWidget); + expect(find.text('Cloud work is still waiting'), findsNothing); + expect(find.textContaining('account-b'), findsNothing); + }); + + testWidgets('raw sign-out failure does not report success or navigate', + (tester) async { + var editorCloses = 0; + final container = _container( + rawSignOut: () async => false, + closeEditor: () async => editorCloses += 1, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign Out')); + await tester.pumpAndSettle(); + + expect(editorCloses, 1); + expect(find.text('Sign out failed'), findsOneWidget); + expect(find.text('Request sign out'), findsOneWidget); + }); +} + +StrategyOpQueueState _pendingQueue() { + return const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'active-strategy', + clientId: 'client-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'active-strategy': StrategyOutboxSummary( + strategyPublicId: 'active-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ); +} + +CloudMediaUploadQueueState _pendingMedia() { + return CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'media-one', + accountId: 'account-a', + strategyPublicId: 'media-strategy', + assetPublicId: 'media-one', + fileExtension: 'png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: true, + attempts: 0, + updatedAt: DateTime.utc(2026), + ), + ], + isProcessing: false, + ); +} + +ProviderContainer _container({ + StrategyOpQueueState opQueue = const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + ), + CloudMediaUploadQueueState mediaQueue = const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ), + CloudSignOutPreparation? preparation, + RawSignOut? rawSignOut, + CloudEditorClose? closeEditor, +}) { + return ProviderContainer(overrides: [ + authProvider.overrideWith(_SignedInAuth.new), + strategyProvider.overrideWith(_ActiveCloudStrategy.new), + strategySaveStateProvider.overrideWith(_CleanSaveState.new), + strategyOpQueueProvider.overrideWith(() => _FixedOpQueue(opQueue)), + cloudMediaUploadQueueProvider.overrideWith( + () => _FixedMediaQueue(mediaQueue), + ), + cloudSignOutPreparationProvider.overrideWithValue( + preparation ?? () async {}, + ), + rawSignOutProvider.overrideWithValue(rawSignOut ?? () async => true), + cloudEditorCloseProvider.overrideWithValue(closeEditor ?? () async {}), + cloudStrategyNamesProvider.overrideWithValue(const { + 'active-strategy': 'Active strategy', + 'closed-strategy': 'Bind retake', + 'media-strategy': 'Ascent execute', + }), + ]); +} + +Future _pumpHarness( + WidgetTester tester, + ProviderContainer container, +) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp(home: _SignOutHarness()), + ), + ); +} + +class _SignOutHarness extends ConsumerWidget { + const _SignOutHarness(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: Center( + child: ShadButton( + onPressed: () => unawaited( + ref.read(cloudSignOutRequestProvider)(context), + ), + child: const Text('Request sign out'), + ), + ), + ); + } +} + +class _SignedInAuth extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _ActiveCloudStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: 'active-strategy', + strategyName: 'Active strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ); +} + +class _PreparingCloudStrategy extends _ActiveCloudStrategy { + int forceSaveCount = 0; + + @override + Future forceSaveNow(String id) async { + forceSaveCount += 1; + } +} + +class _CleanSaveState extends StrategySaveStateNotifier { + @override + StrategySaveState build() => const StrategySaveState( + isDirty: false, + isSaving: false, + hasPendingCloudSync: false, + cloudSyncError: null, + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, + lastPersistedAt: null, + ); +} + +class _FixedOpQueue extends StrategyOpQueueNotifier { + _FixedOpQueue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; +} + +class _FixedMediaQueue extends CloudMediaUploadQueueNotifier { + _FixedMediaQueue(this.initialState); + + final CloudMediaUploadQueueState initialState; + + @override + CloudMediaUploadQueueState build() => initialState; +} + +class _PreparingMediaQueue extends CloudMediaUploadQueueNotifier { + int retryCount = 0; + + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); + + @override + Future retryNow({bool ignoreBackoff = false}) async { + retryCount += 1; + } +} diff --git a/test/cloud_ui_parity_helpers_test.dart b/test/cloud_ui_parity_helpers_test.dart index c15fdcd9..d11e65b5 100644 --- a/test/cloud_ui_parity_helpers_test.dart +++ b/test/cloud_ui_parity_helpers_test.dart @@ -53,6 +53,8 @@ void main() { expect(caps.canRenameStrategy, isFalse); expect(caps.canDeleteStrategy, isFalse); + expect(caps.canMoveStrategy, isFalse); + expect(caps.canEditPages, isFalse); expect(caps.canAddPage, isFalse); expect(caps.canReorderPages, isFalse); }); @@ -62,7 +64,17 @@ void main() { expect(caps.canRenameStrategy, isTrue); expect(caps.canDeleteStrategy, isTrue); + expect(caps.canMoveStrategy, isTrue); + expect(caps.canEditPages, isTrue); expect(caps.canAddPage, isTrue); expect(caps.canReorderPages, isTrue); }); + + test('editor cloud capabilities allow page edits but not moves', () { + final caps = StrategyCapabilities.fromCloudRole('editor'); + + expect(caps.canRenameStrategy, isTrue); + expect(caps.canMoveStrategy, isFalse); + expect(caps.canEditPages, isTrue); + }); } diff --git a/test/collab/cloud_sync_error_message_test.dart b/test/collab/cloud_sync_error_message_test.dart index d69d4227..a006cf9f 100644 --- a/test/collab/cloud_sync_error_message_test.dart +++ b/test/collab/cloud_sync_error_message_test.dart @@ -18,4 +18,15 @@ void main() { expect(message, "Some changes haven't reached the cloud yet."); }); + + test('does not promise local durability when the outbox is uncertain', () { + final message = friendlyCloudSyncError( + 'Cloud work could not be verified in the durable outbox. ' + 'Nothing was sent.', + ); + + expect(message, contains('could not verify')); + expect(message, contains('Nothing was sent')); + expect(message, isNot(contains('remains saved'))); + }); } diff --git a/test/durable_strategy_outbox_version_test.dart b/test/durable_strategy_outbox_version_test.dart index 9c2b5a3b..217e2dad 100644 --- a/test/durable_strategy_outbox_version_test.dart +++ b/test/durable_strategy_outbox_version_test.dart @@ -1,29 +1,89 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/durable_strategy_outbox.dart'; import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; void main() { - test('development outbox is cleared exactly once for record version 2', + test('version 1 outbox records migrate without deleting saved work', () async { - final directory = await Directory.systemTemp.createTemp('icarus-outbox-'); - addTearDown(() async { - await Hive.close(); - await directory.delete(recursive: true); - }); - Hive.init(directory.path); - final box = await Hive.openBox(HiveBoxNames.strategyOutboxBox); - await box.put('legacy-work', {'outboxVersion': 1}); + for (final legacyMarker in [null, 1]) { + final directory = await Directory.systemTemp.createTemp( + 'icarus-outbox-v1-', + ); + try { + Hive.init(directory.path); + final box = await Hive.openBox( + HiveBoxNames.strategyOutboxBox, + ); + final record = _record(); + final legacyJson = record.toJson()..['outboxVersion'] = 1; + const invalidKey = 'unreadable-work'; + final invalidJson = { + 'outboxVersion': 1, + 'opId': 'broken', + }; + if (legacyMarker != null) { + await box.put(durableOutboxVersionKey, legacyMarker); + } + await box.put(record.storageKey, legacyJson); + await box.put(invalidKey, invalidJson); - await prepareDurableStrategyOutbox(); + await prepareDurableStrategyOutbox(); - expect(box.keys, [durableOutboxVersionKey]); - expect(box.get(durableOutboxVersionKey), durableOutboxRecordVersion); + expect( + box.get(durableOutboxVersionKey), + durableOutboxRecordVersion, + ); + expect( + (box.get(record.storageKey) as Map)['outboxVersion'], + durableOutboxRecordVersion, + ); + expect(jsonEncode(box.get(invalidKey)), jsonEncode(invalidJson)); + final loaded = HiveDurableStrategyOutboxStore().load(); + expect(loaded.records.single.pending.op.opId, 'pending-cloud-edit'); + expect(loaded.issues.single.storageKey, invalidKey); - await box.put('v2-work', {'outboxVersion': durableOutboxRecordVersion}); - await prepareDurableStrategyOutbox(); - expect(box.containsKey('v2-work'), isTrue); + await prepareDurableStrategyOutbox(); + expect( + HiveDurableStrategyOutboxStore() + .load() + .records + .single + .pending + .op + .opId, + 'pending-cloud-edit', + ); + } finally { + await Hive.close(); + if (await directory.exists()) { + await directory.delete(recursive: true); + } + } + } }); } + +DurableOutboxRecord _record() { + const op = ElementPatchOp( + opId: 'pending-cloud-edit', + elementPublicId: 'element-one', + pagePublicId: 'page-one', + payload: {'value': 'keep-me'}, + expectedElementRevision: 1, + ); + return DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + entityKey: const EntitySyncKey.element('page-one', 'element-one'), + pending: const PendingOp(op: op, clientId: 'client-a'), + status: DurableOutboxStatus.queued, + createdAt: DateTime.utc(2026, 9, 4), + updatedAt: DateTime.utc(2026, 9, 4), + ); +} diff --git a/test/global_strategy_outbox_test.dart b/test/global_strategy_outbox_test.dart new file mode 100644 index 00000000..0f7051a0 --- /dev/null +++ b/test/global_strategy_outbox_test.dart @@ -0,0 +1,619 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + test('restart drains current-account work across closed strategies', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'strategy-one', opId: 'one')); + await store.put(_record( + strategyId: 'strategy-two', + opId: 'two', + elementId: 'element-two', + )); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + + await _waitUntil(() => repository.calls.length == 2); + expect(repository.calls.map((call) => call.strategyId).toSet(), { + 'strategy-one', + 'strategy-two', + }); + expect(store.values, isEmpty); + expect( + container.read(strategyOpQueueProvider).accountOutbox.hasWork, + isFalse, + ); + }); + + test('background drain never submits another account work', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'strategy-a', opId: 'a')); + await store.put(_record( + accountId: 'account-b', + strategyId: 'strategy-b', + opId: 'b', + )); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setCurrentAccount('account-a'); + await _waitUntil(() => repository.calls.length == 1); + + expect(repository.calls.single.strategyId, 'strategy-a'); + expect(store.values, hasLength(1)); + expect( + container.read(strategyOpQueueProvider).accountOutbox.accountId, + 'account-a', + ); + expect( + container.read(strategyOpQueueProvider).accountOutbox.hasWork, + isFalse, + ); + + notifier.setCurrentAccount('account-b'); + await Future.delayed(const Duration(milliseconds: 50)); + expect(repository.calls, hasLength(1)); + + final accountBContainer = _container( + store: store, + repository: repository, + authAccountId: 'account-b', + ); + addTearDown(accountBContainer.dispose); + accountBContainer + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-b'); + await _waitUntil(() => repository.calls.length == 2); + expect(repository.calls.last.strategyId, 'strategy-b'); + expect(store.values, isEmpty); + }); + + test('active work waits for a background request then runs next', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'closed-strategy', opId: 'closed')); + final repository = _HeldFirstRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('active-strategy', accountId: 'account-a'); + await repository.firstStarted.future; + + await notifier.enqueue( + _op(opId: 'active', elementId: 'active-element'), + flushImmediately: false, + ); + await Future.delayed(const Duration(milliseconds: 220)); + expect(repository.calls, hasLength(1)); + + repository.releaseFirst(); + await _waitUntil(() => repository.calls.length == 2); + expect(repository.calls[0].strategyId, 'closed-strategy'); + expect(repository.calls[1].strategyId, 'active-strategy'); + expect(repository.calls[1].ops.single.opId, 'active'); + }); + + test('opening a draining strategy preserves a concurrent final intent', + () async { + final store = _BlockingSuccessorStore(); + await store.put(_record(strategyId: 'opening', opId: 'predecessor')); + final repository = _HeldFirstRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setCurrentAccount('account-a'); + await repository.firstStarted.future; + + notifier.setActiveStrategy('opening', accountId: 'account-a'); + final enqueue = notifier.enqueue( + _op( + opId: 'final-intent', + elementId: 'element-one', + value: 'final', + ), + ); + await store.successorWriteStarted.future; + repository.releaseFirst(); + await Future.delayed(const Duration(milliseconds: 20)); + expect(repository.calls, hasLength(1)); + + store.allowSuccessorWrite.complete(); + await enqueue; + await _waitUntil(() => repository.calls.length == 2); + final finalOp = repository.calls.last.ops.single as ElementPatchOp; + expect(finalOp.payload, {'value': 'final'}); + expect(finalOp.expectedElementRevision, 2); + }); + + test('one failed closed strategy does not block another', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'fails', opId: 'fails')); + await store.put(_record( + strategyId: 'lands', + opId: 'lands', + elementId: 'element-lands', + )); + final repository = _FailFirstRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + + await _waitUntil(() => repository.calls.length >= 2); + expect(repository.calls.take(2).map((call) => call.strategyId), [ + 'fails', + 'lands', + ]); + expect( + store.values.values + .map((value) => DurableOutboxRecord.fromJson( + Map.from(value as Map), + )) + .where((record) => record.strategyPublicId == 'fails'), + isNotEmpty, + ); + }); + + test('paused and rejected records remain visible and never auto-run', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record( + strategyId: 'paused-strategy', + opId: 'paused', + status: DurableOutboxStatus.paused, + lastError: 'Retry limit reached', + )); + await store.put(_record( + strategyId: 'rejected-strategy', + opId: 'rejected', + elementId: 'rejected-element', + status: DurableOutboxStatus.attention, + lastError: 'Revision conflict', + )); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await Future.delayed(const Duration(milliseconds: 50)); + + final summary = container.read(strategyOpQueueProvider).accountOutbox; + expect(repository.calls, isEmpty); + expect(summary.strategyCount, 2); + expect(summary.needsAttention, isTrue); + expect( + summary.strategies['paused-strategy']!.reason, 'Retry limit reached'); + expect( + summary.strategies['rejected-strategy']!.reason, 'Revision conflict'); + }); + + test('inactive failed writes remain account-visible and unreliable', + () async { + final store = _FailingPutStore(); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('failed-strategy', accountId: 'account-a'); + + await notifier.enqueue( + _op(opId: 'failed-write', elementId: 'failed-element'), + flushImmediately: false, + ); + notifier.setActiveStrategy('other-strategy', accountId: 'account-a'); + + final queue = container.read(strategyOpQueueProvider); + final failedSummary = queue.accountOutbox.strategies['failed-strategy']; + expect(queue.strategyPublicId, 'other-strategy'); + expect(queue.outboxIsReliable, isFalse); + expect(queue.hasDurabilityFailure, isTrue); + expect(failedSummary, isNotNull); + expect(failedSummary!.attentionCount, 1); + expect(failedSummary.queuedCount, 0); + expect(failedSummary.reason, contains('could not be verified')); + expect(repository.calls, isEmpty); + }); + + test( + 'inactive legacy oversized work stays byte-for-byte queued until ' + 'explicit cloud adoption', () async { + final store = MemoryDurableStrategyOutboxStore(); + final oversized = _oversizedOp( + opId: 'legacy-oversized', + elementId: 'large-element', + ); + const key = EntitySyncKey.element('page-one', 'large-element'); + final durable = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'legacy-strategy', + entityKey: key, + pending: PendingOp(op: oversized, clientId: 'legacy-client'), + status: DurableOutboxStatus.queued, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + ); + await store.put(durable); + await store.put(_record( + strategyId: 'unrelated-strategy', + opId: 'unrelated-paused', + status: DurableOutboxStatus.paused, + lastError: 'Retry limit reached', + )); + expect(cloudOperationExceedsPolicy(oversized), isTrue); + final originalBytes = jsonEncode(store.values[durable.storageKey]); + final repository = _RecordingRepository(); + var container = _container(store: store, repository: repository); + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await Future.delayed(const Duration(milliseconds: 50)); + + var summary = container + .read(strategyOpQueueProvider) + .accountOutbox + .strategies['legacy-strategy']; + expect(summary, isNotNull); + expect(summary!.queuedCount, 0); + expect(summary.attentionCount, 1); + expect(summary.reason, cloudOperationTooLargeMessage); + expect(repository.calls, isEmpty); + expect(jsonEncode(store.values[durable.storageKey]), originalBytes); + expect( + store + .load() + .records + .singleWhere( + (record) => record.strategyPublicId == 'legacy-strategy', + ) + .status, + DurableOutboxStatus.queued, + ); + + container.dispose(); + container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setCurrentAccount('account-a'); + await Future.delayed(const Duration(milliseconds: 50)); + + summary = container + .read(strategyOpQueueProvider) + .accountOutbox + .strategies['legacy-strategy']; + expect(summary!.attentionCount, 1); + expect(repository.calls, isEmpty); + expect(jsonEncode(store.values[durable.storageKey]), originalBytes); + expect( + store + .load() + .records + .singleWhere( + (record) => record.strategyPublicId == 'legacy-strategy', + ) + .status, + DurableOutboxStatus.queued, + ); + + notifier.setActiveStrategy('legacy-strategy', accountId: 'account-a'); + final active = container.read(strategyOpQueueProvider); + expect(active.attentionByEntityKey, contains(key)); + expect(active.queuedByEntityKey, isEmpty); + + expect(await notifier.discardRejected({key}), {key}); + expect( + store.load().records.map((record) => record.pending.op.opId), + ['unrelated-paused'], + ); + expect(repository.calls, isEmpty); + }); + + test('reconnection resumes eligible closed-strategy work', () async { + final connectionChanges = StreamController(); + addTearDown(connectionChanges.close); + var connected = false; + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'offline-strategy', opId: 'offline')); + final repository = _RecordingRepository(); + final container = _container( + store: store, + repository: repository, + connected: () => connected, + connectionChanges: connectionChanges.stream, + ); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await Future.delayed(const Duration(milliseconds: 50)); + expect(repository.calls, isEmpty); + expect( + container.read(strategyOpQueueProvider).accountOutbox.hasWork, + isTrue, + ); + + connected = true; + container.invalidate(convexConnectionSnapshotProvider); + connectionChanges.add(true); + await _waitUntil(() => repository.calls.length == 1); + expect(store.values, isEmpty); + }); + + test('auth readiness recovery resumes eligible closed-strategy work', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'auth-waiting', opId: 'auth-op')); + final repository = _RecordingRepository(); + final auth = _MutableAuthProvider(); + final container = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(() => auth), + convexConnectionSnapshotProvider.overrideWithValue(true), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), + ]); + addTearDown(container.dispose); + + container.read(strategyOpQueueProvider); + await Future.delayed(const Duration(milliseconds: 50)); + expect(repository.calls, isEmpty); + + auth.markReady(); + await _waitUntil(() => repository.calls.length == 1); + expect(repository.calls.single.strategyId, 'auth-waiting'); + }); +} + +ProviderContainer _container({ + required DurableStrategyOutboxStore store, + required ConvexStrategyRepository repository, + bool Function()? connected, + Stream? connectionChanges, + String authAccountId = 'account-a', +}) { + return ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(() => _ReadyAuthProvider(authAccountId)), + convexConnectionSnapshotProvider.overrideWith( + (ref) => connected?.call() ?? true, + ), + convexConnectionProvider.overrideWith( + (ref) => connectionChanges ?? Stream.value(true), + ), + ]); +} + +DurableOutboxRecord _record({ + String accountId = 'account-a', + required String strategyId, + required String opId, + String elementId = 'element-one', + DurableOutboxStatus status = DurableOutboxStatus.queued, + String? lastError, +}) { + final now = DateTime(2026); + final op = _op(opId: opId, elementId: elementId); + return DurableOutboxRecord( + accountId: accountId, + strategyPublicId: strategyId, + entityKey: EntitySyncKey.element('page-one', elementId), + pending: PendingOp(op: op, clientId: 'client-$strategyId'), + status: status, + createdAt: now, + updatedAt: now, + lastError: lastError, + ); +} + +ElementPatchOp _op({ + required String opId, + required String elementId, + String value = 'safe', +}) { + return ElementPatchOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-one', + payload: {'value': value}, + expectedElementRevision: 1, + ); +} + +ElementPatchOp _oversizedOp({ + required String opId, + required String elementId, +}) { + return ElementPatchOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-one', + payload: { + 'kind': 'drawing', + 'payloadVersion': 1, + 'data': {'encodedPoints': List.filled(310000, '界').join()}, + }, + expectedElementRevision: 1, + ); +} + +Future _waitUntil(bool Function() condition) async { + for (var i = 0; i < 100; i += 1) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Condition was not reached before timeout.'); +} + +class _ReadyAuthProvider extends AuthProvider { + _ReadyAuthProvider(this.accountId); + + final String accountId; + + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: accountId, + appMetadata: const {}, + userMetadata: const {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _MutableAuthProvider extends AuthProvider { + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + user: _user('account-a'), + ); + + void markReady() { + state = AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: _user('account-a'), + ); + } +} + +User _user(String accountId) => User( + id: accountId, + appMetadata: const {}, + userMetadata: const {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ); + +typedef _Call = ({String strategyId, List ops}); + +class _FailingPutStore extends MemoryDurableStrategyOutboxStore { + @override + Future put(DurableOutboxRecord record) async { + throw StateError('disk write failed'); + } +} + +class _RecordingRepository extends ConvexStrategyRepository { + _RecordingRepository() : super(IcarusConvexApi(_UnusedTransport())); + + final List<_Call> calls = []; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +class _HeldFirstRepository extends _RecordingRepository { + final firstStarted = Completer(); + final _release = Completer(); + + void releaseFirst() => _release.complete(); + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + if (calls.length == 1) { + firstStarted.complete(); + await _release.future; + } + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +class _FailFirstRepository extends _RecordingRepository { + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + if (calls.length == 1) throw StateError('temporary failure'); + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +class _BlockingSuccessorStore extends MemoryDurableStrategyOutboxStore { + final successorWriteStarted = Completer(); + final allowSuccessorWrite = Completer(); + var _blocked = false; + + @override + Future put(DurableOutboxRecord record) async { + if (!_blocked && record.successorPending != null) { + _blocked = true; + successorWriteStarted.complete(); + await allowSuccessorWrite.future; + } + await super.put(record); + } +} + +class _UnusedTransport implements ConvexTransport { + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future mutation(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future query(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Stream subscribe(String name, ConvexObject args) => + throw UnimplementedError(); +} diff --git a/test/image_provider_cloud_durability_test.dart b/test/image_provider_cloud_durability_test.dart new file mode 100644 index 00000000..1f8fb997 --- /dev/null +++ b/test/image_provider_cloud_durability_test.dart @@ -0,0 +1,79 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class _RecordingImageProvider extends PlacedImageProvider { + final List locallySavedImageIds = []; + + @override + Future saveSecureImage( + Uint8List imageBytes, + String imageID, + String fileExtenstion, { + required String? strategyId, + }) async { + locallySavedImageIds.add(imageID); + } +} + +class _FailingMediaQueue extends CloudMediaUploadQueueNotifier { + bool enqueueAttempted = false; + + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); + + @override + Future enqueuePlacedImageUpload({ + required String imagePublicId, + String? strategyPublicId, + String? fileExtension, + String? mimeType, + int? width, + int? height, + }) async { + enqueueAttempted = true; + throw StateError('outbox write failed'); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + + test('failed media outbox write keeps the local source and fails closed', + () async { + final imageProvider = _RecordingImageProvider(); + final mediaQueue = _FailingMediaQueue(); + final container = ProviderContainer( + overrides: [ + placedImageProvider.overrideWith(() => imageProvider), + cloudMediaUploadQueueProvider.overrideWith(() => mediaQueue), + ], + ); + addTearDown(container.dispose); + + await expectLater( + container.read(placedImageProvider.notifier).addImage( + imageBytes: Uint8List.fromList([1, 2, 3]), + strategyId: 'strategy-a', + strategySource: StrategySource.cloud, + fileExtension: '.png', + aspectRatio: 1, + ), + throwsA(isA()), + ); + + expect(mediaQueue.enqueueAttempted, isTrue); + expect(imageProvider.locallySavedImageIds, hasLength(1)); + expect(container.read(placedImageProvider).images, isEmpty); + }); +} diff --git a/test/interactive_map_canonical_center_test.dart b/test/interactive_map_canonical_center_test.dart index dff90fc7..0fceb5e6 100644 --- a/test/interactive_map_canonical_center_test.dart +++ b/test/interactive_map_canonical_center_test.dart @@ -5,6 +5,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/traversal_speed.dart'; import 'package:icarus/interactive_map.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/pen_provider.dart'; @@ -94,4 +95,58 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); }); + + testWidgets('viewer canvas keeps navigation but disables edit layers', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1500, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final container = ProviderContainer( + overrides: [ + mapProvider.overrideWith(_FixedMapProvider.new), + drawingProvider.overrideWith(_EmptyDrawingProvider.new), + penProvider.overrideWith(_FixedPenProvider.new), + effectiveMapThemePaletteProvider.overrideWith( + (ref) => MapThemeProfilesProvider.immutableDefaultPalette, + ), + currentStrategyCapabilitiesProvider.overrideWithValue( + StrategyCapabilities.fromCloudRole('viewer'), + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: InteractiveMap()), + ), + ), + ); + await tester.pump(); + + expect(find.byType(InteractiveViewer), findsOneWidget); + expect( + tester + .widget( + find.byKey(const ValueKey('strategy-canvas-object-editor')), + ) + .ignoring, + isTrue, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('strategy-canvas-drawing-editor')), + ) + .ignoring, + isTrue, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); } diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart index 3a2dd076..710952d2 100644 --- a/test/providers/auth_provider_test.dart +++ b/test/providers/auth_provider_test.dart @@ -1,12 +1,16 @@ import 'dart:async'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/convex_client.dart'; +import 'package:icarus/const/app_navigator.dart'; import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/in_app_debug_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; void main() { @@ -332,6 +336,48 @@ void main() { ); }); + testWidgets('auth incident Sign Out uses the guarded flow', (tester) async { + supabaseApi.currentSession = fakeSession(); + var guardedRequests = 0; + final container = ProviderContainer(overrides: [ + guardedSignOutRequestProvider.overrideWithValue((context) async { + guardedRequests += 1; + return false; + }), + ]); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ShadApp( + navigatorKey: appNavigatorKey, + home: const Scaffold(body: SizedBox.shrink()), + ), + ), + ); + final notifier = container.read(authProvider.notifier); + await tester.pump(); + await tester.pump(); + + await notifier.reportConvexUnauthenticated( + source: 'test:incident-route', + error: const ConvexClientFunctionError( + rawCode: 'UNAUTHENTICATED', + message: 'Authentication required', + data: null, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text('Cloud connection lost'), findsOneWidget); + + await tester.tap(find.text('Sign Out')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(guardedRequests, 1); + expect(supabaseApi.currentSession, isNotNull); + }); + test('auth readiness timeout surfaces as setup incident, not unauthenticated', () async { supabaseApi.currentSession = fakeSession(); diff --git a/test/providers/cloud_library_action_providers_test.dart b/test/providers/cloud_library_action_providers_test.dart new file mode 100644 index 00000000..8a05f09a --- /dev/null +++ b/test/providers/cloud_library_action_providers_test.dart @@ -0,0 +1,495 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDirectory; + + setUp(() async { + tempDirectory = await Directory.systemTemp.createTemp( + 'icarus-cloud-library-actions-', + ); + Hive.init(tempDirectory.path); + await Hive.openBox(HiveBoxNames.pinnedItemsBox); + }); + + tearDown(() async { + await Hive.close(); + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + test('failed cloud folder delete preserves selection, pin, and streams', + () async { + final repository = _ActionRepository()..failDeleteFolder = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + final notifier = harness.container.read(folderProvider.notifier); + notifier.updateID('folder-1'); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('folder-1'); + + final result = await notifier.deleteFolder( + 'folder-1', + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(result.userMessage, "Couldn't delete this cloud folder. Try again."); + expect(result.userMessage, isNot(contains(_ActionRepository.secret))); + expect(harness.container.read(folderProvider), 'folder-1'); + expect(harness.container.read(pinnedItemsProvider), contains('folder-1')); + expect(harness.cloudFolderBuilds, 1); + expect(harness.allCloudFolderBuilds, 1); + expect(harness.cloudStrategyBuilds, 1); + expect(harness.messages, isEmpty); + }); + + test('successful cloud folder delete mutates local UI state once', () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + final notifier = harness.container.read(folderProvider.notifier); + notifier.updateID('folder-1'); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('folder-1'); + + final result = await notifier.deleteFolder( + 'folder-1', + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.deleteFolderCalls, 1); + expect(harness.container.read(folderProvider), isNull); + expect( + harness.container.read(pinnedItemsProvider), + isNot(contains('folder-1')), + ); + expect(harness.cloudFolderBuilds, 2); + expect(harness.allCloudFolderBuilds, 2); + expect(harness.cloudStrategyBuilds, 2); + }); + + test('cloud folder update is awaitable and invalidates only after success', + () async { + final gate = Completer(); + final repository = _ActionRepository()..updateFolderGate = gate; + final harness = _Harness(repository); + addTearDown(harness.dispose); + final folder = _folder('folder-1'); + + final pending = harness.container.read(folderProvider.notifier).editFolder( + folder: folder, + newName: 'Retakes', + newIconId: folder.iconId, + newColor: folder.color, + newCustomColor: folder.customColor, + workspace: LibraryWorkspace.cloud, + ); + var completed = false; + pending.then((_) => completed = true); + await _pumpMicrotasks(); + expect(completed, isFalse); + expect(harness.cloudFolderBuilds, 1); + + gate.complete(); + final result = await pending; + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.updateFolderCalls, 1); + expect(harness.cloudFolderBuilds, 2); + expect(harness.allCloudFolderBuilds, 2); + }); + + test('failed cloud folder update preserves model and does not invalidate', + () async { + final repository = _ActionRepository()..failUpdateFolder = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + final folder = _folder('folder-1'); + + final result = + await harness.container.read(folderProvider.notifier).editFolder( + folder: folder, + newName: 'Injected ${_ActionRepository.secret}', + newIconId: folder.iconId, + newColor: folder.color, + newCustomColor: folder.customColor, + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(result.userMessage, isNot(contains(_ActionRepository.secret))); + expect(folder.name, 'Defaults'); + expect(harness.cloudFolderBuilds, 1); + expect(harness.allCloudFolderBuilds, 1); + expect(harness.messages, isEmpty); + }); + + test('failed cloud folder move is awaitable, visible, and does not refresh', + () async { + final gate = Completer(); + final repository = _ActionRepository() + ..moveFolderGate = gate + ..failMoveFolder = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final pending = + harness.container.read(folderProvider.notifier).moveToFolder( + folderID: 'folder-1', + parentID: 'folder-2', + workspace: LibraryWorkspace.cloud, + ); + var completed = false; + pending.then((_) => completed = true); + await _pumpMicrotasks(); + expect(completed, isFalse); + + gate.complete(); + final result = await pending; + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(harness.cloudFolderBuilds, 1); + expect(harness.allCloudFolderBuilds, 1); + expect(harness.messages, ["Couldn't move this cloud folder. Try again."]); + expect(harness.messages.single, isNot(contains(_ActionRepository.secret))); + }); + + test('successful cloud folder move refreshes both folder views once', + () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final result = + await harness.container.read(folderProvider.notifier).moveToFolder( + folderID: 'folder-1', + parentID: 'folder-2', + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.moveFolderCalls, 1); + expect(harness.cloudFolderBuilds, 2); + expect(harness.allCloudFolderBuilds, 2); + expect(harness.messages, isEmpty); + }); + + test('cloud strategy delete keeps pin and stream when the server rejects it', + () async { + final repository = _ActionRepository()..failDeleteStrategy = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('strategy-1'); + + final result = await harness.container + .read(strategyProvider.notifier) + .deleteStrategy('strategy-1', source: StrategySource.cloud); + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(result.userMessage, isNot(contains(_ActionRepository.secret))); + expect(harness.container.read(pinnedItemsProvider), contains('strategy-1')); + expect(harness.cloudStrategyBuilds, 1); + expect(harness.messages, isEmpty); + }); + + test('cloud strategy delete removes pin and refreshes only after success', + () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('strategy-1'); + + final result = await harness.container + .read(strategyProvider.notifier) + .deleteStrategy('strategy-1', source: StrategySource.cloud); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.deleteStrategyCalls, 1); + expect( + harness.container.read(pinnedItemsProvider), + isNot(contains('strategy-1')), + ); + expect(harness.cloudStrategyBuilds, 2); + }); + + test('failed cloud strategy move is awaitable, visible, and does not refresh', + () async { + final gate = Completer(); + final repository = _ActionRepository() + ..moveStrategyGate = gate + ..failMoveStrategy = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final pending = + harness.container.read(strategyProvider.notifier).moveToFolder( + strategyID: 'strategy-1', + parentID: 'folder-2', + source: StrategySource.cloud, + ); + var completed = false; + pending.then((_) => completed = true); + await _pumpMicrotasks(); + expect(completed, isFalse); + + gate.complete(); + final result = await pending; + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(harness.cloudStrategyBuilds, 1); + expect( + harness.messages, + ["Couldn't move this cloud strategy. Try again."], + ); + expect(harness.messages.single, isNot(contains(_ActionRepository.secret))); + }); + + test('successful cloud strategy move refreshes the library once', () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final result = + await harness.container.read(strategyProvider.notifier).moveToFolder( + strategyID: 'strategy-1', + parentID: 'folder-2', + source: StrategySource.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.moveStrategyCalls, 1); + expect(harness.cloudStrategyBuilds, 2); + expect(harness.messages, isEmpty); + }); +} + +class _Harness { + _Harness(this.repository) { + container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => auth), + libraryWorkspaceProvider.overrideWith(_CloudWorkspaceNotifier.new), + convexStrategyRepositoryProvider.overrideWithValue(repository), + cloudLibraryActionReporterProvider.overrideWithValue( + CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ), + ), + cloudFoldersProvider.overrideWith((_) { + cloudFolderBuilds += 1; + return Stream.value(const []); + }), + cloudAllFoldersProvider.overrideWith((_) { + allCloudFolderBuilds += 1; + return Stream.value(const []); + }), + cloudStrategiesProvider.overrideWith((_) { + cloudStrategyBuilds += 1; + return Stream.value(const []); + }), + ], + ); + container.listen(cloudFoldersProvider, (_, __) {}, fireImmediately: true); + container.listen( + cloudAllFoldersProvider, + (_, __) {}, + fireImmediately: true, + ); + container.listen( + cloudStrategiesProvider, + (_, __) {}, + fireImmediately: true, + ); + } + + final _ActionRepository repository; + final _ReadyAuthProvider auth = _ReadyAuthProvider(); + final List messages = []; + late final ProviderContainer container; + int cloudFolderBuilds = 0; + int allCloudFolderBuilds = 0; + int cloudStrategyBuilds = 0; + + void dispose() => container.dispose(); +} + +class _CloudWorkspaceNotifier extends LibraryWorkspaceNotifier { + @override + LibraryWorkspace build() => LibraryWorkspace.cloud; +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +class _ActionRepository extends ConvexStrategyRepository { + _ActionRepository() : super(IcarusConvexApi(_UnusedTransport())); + + static const secret = 'Bearer super-secret-backend-detail'; + + bool failDeleteFolder = false; + bool failUpdateFolder = false; + bool failMoveFolder = false; + bool failDeleteStrategy = false; + bool failMoveStrategy = false; + Completer? updateFolderGate; + Completer? moveFolderGate; + Completer? moveStrategyGate; + int deleteFolderCalls = 0; + int updateFolderCalls = 0; + int deleteStrategyCalls = 0; + int moveFolderCalls = 0; + int moveStrategyCalls = 0; + + @override + Future deleteFolder(String folderPublicId) async { + deleteFolderCalls += 1; + if (failDeleteFolder) throw StateError(secret); + } + + @override + Future updateFolder({ + required String folderPublicId, + String? name, + int? iconId, + int? iconCodePoint, + String? iconFontFamily, + bool clearIconFontFamily = false, + String? iconFontPackage, + bool clearIconFontPackage = false, + String? color, + int? customColorValue, + bool clearCustomColorValue = false, + }) async { + updateFolderCalls += 1; + await updateFolderGate?.future; + if (failUpdateFolder) throw StateError(secret); + } + + @override + Future moveFolder({ + required String folderPublicId, + String? parentFolderPublicId, + }) async { + moveFolderCalls += 1; + await moveFolderGate?.future; + if (failMoveFolder) throw StateError(secret); + } + + @override + Future fetchShell(String strategyPublicId) async { + final now = DateTime.utc(2026); + return RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: strategyPublicId, + name: 'A Split', + mapData: 'Ascent', + revision: 4, + createdAt: now, + updatedAt: now, + ), + pages: const [], + ); + } + + @override + Future deleteStrategy({ + required String strategyPublicId, + required int expectedRevision, + }) async { + deleteStrategyCalls += 1; + if (failDeleteStrategy) throw StateError(secret); + } + + @override + Future moveStrategy({ + required String strategyPublicId, + String? folderPublicId, + required int expectedRevision, + }) async { + moveStrategyCalls += 1; + await moveStrategyGate?.future; + if (failMoveStrategy) throw StateError(secret); + } +} + +Folder _folder(String id) => Folder( + id: id, + name: 'Defaults', + iconId: 0, + dateCreated: DateTime.utc(2026), + color: FolderColor.generic, + ); + +Future _pumpMicrotasks() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +class _UnusedTransport implements ConvexTransport { + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future mutation(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future query(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Stream subscribe(String name, ConvexObject args) => + throw UnimplementedError(); +} diff --git a/test/providers/folder_provider_test.dart b/test/providers/folder_provider_test.dart index 0fcd6863..7ec217ce 100644 --- a/test/providers/folder_provider_test.dart +++ b/test/providers/folder_provider_test.dart @@ -5,8 +5,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/generated/generated.dart'; import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; void main() { test('failed cloud folder deletion preserves the selected folder', () async { @@ -50,11 +53,35 @@ ProviderContainer _createContainer(ConvexStrategyRepository repository) { return ProviderContainer( overrides: [ libraryWorkspaceProvider.overrideWith(_CloudWorkspaceNotifier.new), + pinnedItemsProvider.overrideWith(_MemoryPinnedItemsProvider.new), convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_ReadyAuthProvider.new), + cloudFoldersProvider.overrideWith((_) => Stream.value(const [])), + cloudAllFoldersProvider.overrideWith((_) => Stream.value(const [])), + cloudStrategiesProvider.overrideWith((_) => Stream.value(const [])), ], ); } +class _MemoryPinnedItemsProvider extends PinnedItemsProvider { + @override + Map build() => const {}; + + @override + Future removePin(String id) async {} +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + class _CloudWorkspaceNotifier extends LibraryWorkspaceNotifier { @override LibraryWorkspace build() => LibraryWorkspace.cloud; diff --git a/test/strategy_edit_access_test.dart b/test/strategy_edit_access_test.dart new file mode 100644 index 00000000..8e5b818f --- /dev/null +++ b/test/strategy_edit_access_test.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/shortcut_info.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/global_shortcuts.dart'; +import 'package:icarus/widgets/strategy_edit_boundary.dart'; + +class _RoleSnapshotNotifier extends RemoteEditorSnapshotNotifier { + _RoleSnapshotNotifier(this.role); + + final String role; + + @override + Future build() async { + final now = DateTime.utc(2026); + return RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'cloud-strategy', + name: 'Cloud Strategy', + mapData: Maps.mapNames[MapValue.ascent]!, + revision: 1, + createdAt: now, + updatedAt: now, + role: role, + ), + pages: const [], + ), + activePage: null, + ); + } +} + +class _RecordingStrategyOpQueue extends StrategyOpQueueNotifier { + final enqueued = []; + + @override + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'test-client', + durableLoaded: true, + ); + + @override + Future enqueueAll( + Iterable ops, { + bool flushImmediately = false, + }) async { + enqueued.addAll(ops); + } +} + +Future _invokeAddTextShortcut( + WidgetTester tester, + StrategyCapabilities capabilities, +) async { + late WidgetRef widgetRef; + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentStrategyCapabilitiesProvider.overrideWithValue(capabilities), + ], + child: MaterialApp( + home: GlobalShortcuts( + child: Consumer( + builder: (context, ref, _) { + widgetRef = ref; + return const SizedBox( + key: ValueKey('shortcut-target'), + width: 100, + height: 100, + ); + }, + ), + ), + ), + ), + ); + + Actions.invoke( + tester.element(find.byKey(const ValueKey('shortcut-target'))), + const AddedTextIntent(), + ); + await tester.pump(); + + expect( + widgetRef.read(textProvider).length, + capabilities.canEditPages ? 1 : 0, + ); +} + +void main() { + testWidgets('viewers cannot add text with an editor shortcut', + (tester) async { + await _invokeAddTextShortcut( + tester, + StrategyCapabilities.fromCloudRole('viewer'), + ); + }); + + for (final role in ['editor', 'owner']) { + testWidgets('$role can add text with an editor shortcut', (tester) async { + await _invokeAddTextShortcut( + tester, + StrategyCapabilities.fromCloudRole(role), + ); + }); + } + + testWidgets('view-only edit controls absorb pointer input', (tester) async { + var taps = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentStrategyCapabilitiesProvider.overrideWithValue( + StrategyCapabilities.fromCloudRole('viewer'), + ), + ], + child: MaterialApp( + home: StrategyEditBoundary( + disabledOpacity: 0.55, + child: GestureDetector( + key: const ValueKey('edit-control'), + onTap: () => taps += 1, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ), + ); + + await tester.tap( + find.byKey(const ValueKey('edit-control')), + warnIfMissed: false, + ); + expect(taps, 0); + expect( + tester + .widget( + find.descendant( + of: find.byType(StrategyEditBoundary), + matching: find.byType(ExcludeFocus), + ), + ) + .excluding, + isTrue, + ); + expect( + tester + .widget( + find.descendant( + of: find.byType(StrategyEditBoundary), + matching: find.byType(Opacity), + ), + ) + .opacity, + 0.55, + ); + }); + + test('a stale cloud role cannot grant access to another strategy', () async { + final container = ProviderContainer( + overrides: [ + remoteEditorSnapshotProvider.overrideWith( + () => _RoleSnapshotNotifier('editor'), + ), + ], + ); + addTearDown(container.dispose); + await container.read(remoteEditorSnapshotProvider.future); + container.read(strategyProvider.notifier).setFromState( + const StrategyState( + strategyId: 'different-cloud-strategy', + strategyName: 'Different Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + ); + + expect( + container.read(currentStrategyCapabilitiesProvider).canEditPages, + isFalse, + ); + }); + + for (final role in ['viewer', 'editor', 'owner']) { + test('$role cloud op queue access matches page edit capability', () async { + final queue = _RecordingStrategyOpQueue(); + final container = ProviderContainer( + overrides: [ + remoteEditorSnapshotProvider.overrideWith( + () => _RoleSnapshotNotifier(role), + ), + strategyOpQueueProvider.overrideWith(() => queue), + ], + ); + addTearDown(container.dispose); + await container.read(remoteEditorSnapshotProvider.future); + container.read(strategyProvider.notifier).setFromState( + const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + ); + + await container.read(strategyProvider.notifier).enqueueOps( + const [ + StrategyPatchOp( + opId: 'strategy-op', + payload: {'name': 'Changed'}, + expectedStrategyRevision: 1, + ), + ], + ); + + expect( + queue.enqueued.length, + role == 'viewer' ? 0 : 1, + ); + }); + } +} diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index 53ceaf05..e057b140 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -42,6 +43,13 @@ void main() { container?.dispose(); container = ProviderContainer(overrides: [ durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), ]); container! .read(cloudCollabModeProvider.notifier) @@ -240,21 +248,24 @@ void main() { expect(store.values, contains('broken')); }); - test('protocol-v2 outbox records fail closed instead of converting', () { - final legacy = record(status: DurableOutboxStatus.queued).toJson() - ..['outboxVersion'] = 1; + test('unknown future outbox records fail closed instead of converting', () { + final future = record(status: DurableOutboxStatus.queued).toJson() + ..['outboxVersion'] = durableOutboxRecordVersion + 1; expect( - () => DurableOutboxRecord.fromJson(legacy), + () => DurableOutboxRecord.fromJson(future), throwsA(isA()), ); }); - test('reconciliation replaces rejected immutable opId before removal', + test('reconciliation retains rejected work and updates its successor', () async { - final saved = record(status: DurableOutboxStatus.attention); + final saved = record(status: DurableOutboxStatus.attention).copyWith( + latestServerRevision: 7, + lastError: 'revision_mismatch', + ); await store.put(saved); - final notifier = start(); + var notifier = start(); await notifier.syncDesiredOpsForPage( pageId: 'page-1', desiredOpsByEntityKey: { @@ -263,14 +274,58 @@ void main() { }, flushImmediately: false, ); - final current = container!.read(strategyOpQueueProvider); - expect(current.attentionByEntityKey, isEmpty); - expect(current.queuedByEntityKey.values.single.pending.op.opId, - 'replacement'); + var current = container!.read(strategyOpQueueProvider); + expect( + current.attentionByEntityKey.values.single.pending.op.opId, 'op-1'); + expect(current.queuedByEntityKey, isEmpty); + expect( + current.successorByEntityKey.values.single.pending.op.payload, + {'value': 'new'}, + ); + + notifier = start(); + current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, hasLength(1)); + expect(current.successorByEntityKey, hasLength(1)); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + const EntitySyncKey.element('page-1', 'element-1'): + elementOp(opId: 'newer-replacement', value: 'newest'), + }, + flushImmediately: false, + ); + + current = container!.read(strategyOpQueueProvider); expect( - (store.values.values.single as Map)['opId'], - 'replacement', + current.attentionByEntityKey.values.single.pending.op.opId, 'op-1'); + expect(current.queuedByEntityKey, isEmpty); + expect( + current.successorByEntityKey.values.single.pending.op.payload, + {'value': 'newest'}, + ); + var durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'op-1'); + expect(durable.successorPending!.op.payload, {'value': 'newest'}); + expect(durable.latestServerRevision, 7); + expect(durable.lastError, 'revision_mismatch'); + + await notifier.retryRejected(flushImmediately: false); + current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.successorByEntityKey, isEmpty); + final retry = current.queuedByEntityKey.values.single.pending.op; + expect(retry.opId, isNot(anyOf('op-1', 'newer-replacement'))); + expect(retry.payload, {'value': 'newest'}); + expect(retry.expectedRevision, 7); + durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), ); + expect(durable.status, DurableOutboxStatus.queued); + expect(durable.successorPending, isNull); }); test('ordinary reconciliation does not discard attention work', () async { @@ -287,6 +342,165 @@ void main() { expect(store.values, hasLength(1)); }); + test( + 'cloud adoption discards only selected rejected work and survives restart', + () async { + const selectedKey = EntitySyncKey.element('page-1', 'element-1'); + const otherAttentionKey = + EntitySyncKey.element('page-1', 'element-2'); + const queuedKey = EntitySyncKey.element('page-1', 'element-3'); + final selected = record(status: DurableOutboxStatus.attention).copyWith( + successorPending: PendingOp( + op: elementOp( + opId: 'selected-successor', + value: 'newer local intent', + ), + clientId: 'stable-client', + ), + latestServerRevision: 7, + ); + final otherAttention = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: otherAttentionKey, + pending: PendingOp( + op: elementOp(opId: 'other-rejected', elementId: 'element-2'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + latestServerRevision: 4, + ); + final queued = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: queuedKey, + pending: PendingOp( + op: elementOp(opId: 'unrelated-queued', elementId: 'element-3'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.queued, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + final otherStrategy = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-2', + entityKey: selectedKey, + pending: PendingOp( + op: elementOp(opId: 'other-strategy'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + await store.put(selected); + await store.put(otherAttention); + await store.put(queued); + await store.put(otherStrategy); + var notifier = start(); + + final discarded = await notifier.discardRejected({selectedKey}); + + expect(discarded, {selectedKey}); + var current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(otherAttentionKey)); + expect(current.attentionByEntityKey, isNot(contains(selectedKey))); + expect(current.successorByEntityKey, isEmpty); + expect(current.queuedByEntityKey, contains(queuedKey)); + expect( + store.load().records.map((record) => record.pending.op.opId), + containsAll([ + 'other-rejected', + 'unrelated-queued', + 'other-strategy', + ]), + ); + expect( + store.load().records.map((record) => record.pending.op.opId), + isNot(contains('op-1')), + ); + expect( + store.load().records + .expand((record) => [ + record.pending.op.opId, + if (record.successorPending != null) + record.successorPending!.op.opId, + ]), + isNot(contains('selected-successor')), + ); + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + selectedKey: elementOp(opId: 'stale-reconciliation'), + }, + clearMissing: false, + flushImmediately: false, + ); + expect( + container!.read(strategyOpQueueProvider).queuedByEntityKey, + isNot(contains(selectedKey)), + ); + + notifier = start(); + current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(otherAttentionKey)); + expect(current.attentionByEntityKey, isNot(contains(selectedKey))); + expect(current.queuedByEntityKey, contains(queuedKey)); + expect(current.pending.map((pending) => pending.op.opId), + isNot(contains('selected-successor'))); + + notifier.setActiveStrategy('strategy-2', accountId: 'account-a'); + expect( + container! + .read(strategyOpQueueProvider) + .attentionByEntityKey[selectedKey]! + .pending + .op + .opId, + 'other-strategy', + ); + }); + + test('partial cloud adoption leaves a failed durable delete in attention', + () async { + const firstKey = EntitySyncKey.element('page-1', 'element-1'); + const secondKey = EntitySyncKey.element('page-1', 'element-2'); + final failingStore = _FailingSelectedRemovalStore(); + store = failingStore; + await store.put(record(status: DurableOutboxStatus.attention)); + final second = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: secondKey, + pending: PendingOp( + op: elementOp(opId: 'second', elementId: 'element-2'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + await store.put(second); + failingStore.failStorageKey = second.storageKey; + final notifier = start(); + + final discarded = await notifier.discardRejected({firstKey, secondKey}); + + expect(discarded, {firstKey}); + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(secondKey)); + expect(current.attentionByEntityKey, isNot(contains(firstKey))); + expect(current.lastError, contains('could not be removed')); + expect( + store.load().records.map((record) => record.entityKey), + unorderedEquals([secondKey]), + ); + }); + test('explicit rejected retry uses durable latest server revision', () async { final saved = record(status: DurableOutboxStatus.attention).copyWith( @@ -536,6 +750,13 @@ void main() { final store = _BlockingStore(); final container = ProviderContainer(overrides: [ durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), ]); addTearDown(container.dispose); final notifier = container.read(strategyOpQueueProvider.notifier) @@ -562,6 +783,13 @@ void main() { final store = _BlockingReplacementStore(); final container = ProviderContainer(overrides: [ durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), ]); addTearDown(container.dispose); final notifier = container.read(strategyOpQueueProvider.notifier) @@ -598,45 +826,189 @@ void main() { expect(afterWrite.op.payload, {'value': 'b'}); }); - group('acknowledgement persistence recovery', () { - test('accepted ack remove failure restores the batch for retry', () async { - final store = _OneShotAckFailureStore(failRemove: true); + test('cloud adoption leaves unrelated in-flight work untouched', () async { + const rejectedKey = EntitySyncKey.element('page-1', 'element-1'); + final store = MemoryDurableStrategyOutboxStore(); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: rejectedKey, + pending: const PendingOp( + op: ElementPatchOp( + opId: 'rejected', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'mine'}, + expectedElementRevision: 1, + ), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + )); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const inFlightKey = EntitySyncKey.element('page-1', 'element-2'); + await notifier.enqueue(const ElementPatchOp( + opId: 'unrelated-in-flight', + elementPublicId: 'element-2', + pagePublicId: 'page-1', + payload: {'value': 'other'}, + expectedElementRevision: 1, + )); + final flush = notifier.flushNow(); + await repository.firstStarted.future; + + final discarded = await notifier.discardRejected({rejectedKey}); + + expect(discarded, {rejectedKey}); + expect( + container.read(strategyOpQueueProvider).inFlightByEntityKey, + contains(inFlightKey), + ); + repository.completeFirst(const AppliedOpAck( + opId: 'unrelated-in-flight', + revision: 2, + )); + await flush; + }); + + group('cloud payload policy', () { + test('serialized operation size counts Unicode UTF-8 bytes', () { + final op = _largeElementPatch( + opId: 'unicode-size', + elementId: 'element-unicode', + value: _repeat('界', 3), + ); + final serialized = jsonEncode(op.toConvexJson()); + + expect( + serializedCloudOperationUtf8Bytes(op), + utf8.encode(serialized).length, + ); + expect(utf8.encode(serialized).length, greaterThan(serialized.length)); + }); + + test( + 'an initial durable enqueue failure stays unreliable until the exact ' + 'record is rewritten', () async { + final store = _FirstPutFailureStore(); final container = _cloudQueueContainer( store: store, - repository: _AckRepository(reject: false), + repository: _RecordingAckRepository(), ); addTearDown(container.dispose); final notifier = container.read(strategyOpQueueProvider.notifier) ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const op = ElementPatchOp( + opId: 'initial-write-failure', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'unsaved'}, + expectedElementRevision: 1, + ); + const key = EntitySyncKey.element('page-1', 'element-1'); - await notifier.enqueue(_cloudElementOp(), flushImmediately: false); - await notifier.flushNow(); + await notifier.enqueue(op, flushImmediately: false); - _expectBatchRestored(container, store); + var current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey[key]!.pending.op, op); + expect(current.hasDurabilityFailure, isTrue); + expect(current.needsAttention, isTrue); + expect(current.outboxIsReliable, isFalse); + expect(current.lastError, contains('could not be saved')); + expect(store.values, isEmpty); + + await notifier.enqueue(op, flushImmediately: false); + + current = container.read(strategyOpQueueProvider); + expect(current.queuedByEntityKey, contains(key)); + expect(current.hasDurabilityFailure, isFalse); + expect(current.outboxIsReliable, isTrue); + expect(current.lastError, isNull); + expect(store.load().records.single.pending.op.opId, op.opId); }); - test('rejected ack put failure restores the batch for retry', () async { - final store = _OneShotAckFailureStore(failAttentionPut: true); - final container = _cloudQueueContainer( + test('an oversized op is durably parked while independent work lands', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _RecordingAckRepository(); + var container = _cloudQueueContainer( store: store, - repository: _AckRepository(reject: true), + repository: repository, ); - addTearDown(container.dispose); - final notifier = container.read(strategyOpQueueProvider.notifier) + var notifier = container.read(strategyOpQueueProvider.notifier) ..setActiveStrategy('strategy-1', accountId: 'account-a'); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final oversized = _largeElementPatch( + opId: 'oversized', + elementId: 'element-large', + ); + const valid = ElementPatchOp( + opId: 'independent', + elementPublicId: 'element-small', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedElementRevision: 1, + ); + expect( + serializedCloudOperationUtf8Bytes(oversized), + greaterThan(maxCloudOperationBytes), + ); + await notifier.enqueue(oversized, flushImmediately: false); + await notifier.enqueue(valid, flushImmediately: false); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(false); - await notifier.enqueue(_cloudElementOp(), flushImmediately: false); await notifier.flushNow(); - _expectBatchRestored(container, store); + expect(repository.calls, hasLength(1)); + expect(repository.calls.single.map((op) => op.opId), ['independent']); + var current = container.read(strategyOpQueueProvider); + const oversizedKey = + EntitySyncKey.element('page-1', 'element-large'); + expect(current.attentionByEntityKey, contains(oversizedKey)); + expect(current.queuedByEntityKey, isEmpty); + expect(current.lastError, cloudOperationTooLargeMessage); + var durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'oversized'); + expect(durable.lastError, cloudOperationTooLargeMessage); + + container.dispose(); + repository.calls.clear(); + container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.flushNow(); + + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(oversizedKey)); + expect(current.lastError, cloudOperationTooLargeMessage); + expect(repository.calls, isEmpty); + durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'oversized'); }); - }); - group('page descriptor final intent', () { - test('rebases and sends the final side after an in-flight side patch', + test('an over-wide array is parked before independent transport', () async { final store = MemoryDurableStrategyOutboxStore(); - final repository = _SequencedAckRepository(); + final repository = _RecordingAckRepository(); final container = _cloudQueueContainer( store: store, repository: repository, @@ -644,130 +1016,1189 @@ void main() { addTearDown(container.dispose); final notifier = container.read(strategyOpQueueProvider.notifier) ..setActiveStrategy('strategy-1', accountId: 'account-a'); - const key = EntitySyncKey.pageDescriptor('page-1'); - - await notifier.enqueue(_pageSideOp( - opId: 'defense', - isAttack: false, - expectedRevision: 1, - )); - final firstFlush = notifier.flushNow(); - await repository.firstStarted.future; - - await notifier.syncDesiredOpsForPage( - pageId: 'page-1', - desiredOpsByEntityKey: { - key: _pageSideOp( - opId: 'attack', - isAttack: true, - expectedRevision: 1, - ), + final wide = ElementPatchOp( + opId: 'wide-array', + elementPublicId: 'element-wide', + pagePublicId: 'page-1', + payload: { + 'kind': 'drawing', + 'payloadVersion': 1, + 'data': {'points': List.filled(maxCloudArrayEntries + 1, 0)}, }, + expectedElementRevision: 1, ); - - final duringFirst = container.read(strategyOpQueueProvider); - expect(duringFirst.inFlightByEntityKey[key]!.pending.op.opId, 'defense'); expect( - duringFirst.successorByEntityKey[key]!.pending.op.payload, - {'isAttack': true}, + serializedCloudOperationUtf8Bytes(wide), + lessThan(maxCloudOperationBytes), ); - final durableDuringFirst = DurableOutboxRecord.fromJson( - Map.from(store.values.values.single as Map), + expect(cloudOperationExceedsPolicy(wide), isTrue); + await notifier.enqueue(wide, flushImmediately: false); + await notifier.enqueue( + const ElementPatchOp( + opId: 'wide-array-sibling', + elementPublicId: 'element-small', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedElementRevision: 1, + ), + flushImmediately: false, ); - expect(durableDuringFirst.pending.op.opId, 'defense'); - expect(durableDuringFirst.successorPending!.op.payload, { - 'isAttack': true, - }); - - repository.completeFirst(const AppliedOpAck( - opId: 'defense', - revision: 2, - )); - await firstFlush; - await repository.secondStarted.future; - final promoted = repository.calls[1].single as PagePatchOp; - expect(promoted.payload, {'isAttack': true}); - expect(promoted.expectedPageRevision, 2); - expect(promoted.opId, isNot('attack')); + await notifier.flushNow(); - repository.completeSecond(AppliedOpAck( - opId: promoted.opId, - revision: 3, - )); - await repository.secondCompleted.future; - await Future.delayed(Duration.zero); - expect(container.read(strategyOpQueueProvider).pending, isEmpty); - expect(store.values, isEmpty); + expect(repository.calls, hasLength(1)); + expect(repository.calls.single.map((op) => op.opId), [ + 'wide-array-sibling', + ]); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + contains(const EntitySyncKey.element('page-1', 'element-wide')), + ); + expect(store.load().records.single.lastError, + cloudOperationTooLargeMessage); }); - test('restart replays the predecessor before its durable final side', + test('a failed oversized parking write blocks all transport and retries', () async { - final store = MemoryDurableStrategyOutboxStore(); - final firstRepository = _SequencedAckRepository(); - var container = _cloudQueueContainer( + final store = _OversizedParkingFailureStore(); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( store: store, - repository: firstRepository, + repository: repository, ); - var notifier = container.read(strategyOpQueueProvider.notifier) + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) ..setActiveStrategy('strategy-1', accountId: 'account-a'); - const key = EntitySyncKey.pageDescriptor('page-1'); - - await notifier.enqueue(_pageSideOp( - opId: 'defense-before-restart', - isAttack: false, - expectedRevision: 4, - )); - unawaited(notifier.flushNow()); + await notifier.enqueue( + _largeElementPatch( + opId: 'oversized-write-failure', + elementId: 'element-large', + ), + flushImmediately: false, + ); + await notifier.enqueue( + const ElementPatchOp( + opId: 'independent-after-write-failure', + elementPublicId: 'element-small', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedElementRevision: 1, + ), + flushImmediately: false, + ); + + await notifier.flushNow(); + await Future.delayed(const Duration(milliseconds: 400)); + + final current = container.read(strategyOpQueueProvider); + expect(repository.calls, isEmpty); + expect(current.outboxIsReliable, isFalse); + expect(current.hasDurabilityFailure, isTrue); + expect( + current.attentionByEntityKey, + contains(const EntitySyncKey.element('page-1', 'element-large')), + ); + expect( + current.queuedByEntityKey, + contains(const EntitySyncKey.element('page-1', 'element-small')), + ); + expect(current.lastError, contains('Nothing was sent')); + expect(store.attentionWrites, 1); + }); + + test('a missing durable oversized record blocks all transport', () async { + final store = _OversizedParkingFailureStore(dropBeforeThrow: true); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.enqueue( + _largeElementPatch( + opId: 'oversized-missing-record', + elementId: 'element-large', + ), + flushImmediately: false, + ); + await notifier.enqueue( + const ElementPatchOp( + opId: 'independent-after-missing-record', + elementPublicId: 'element-small', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedElementRevision: 1, + ), + flushImmediately: false, + ); + + await notifier.flushNow(); + await Future.delayed(const Duration(milliseconds: 400)); + + final current = container.read(strategyOpQueueProvider); + expect(repository.calls, isEmpty); + expect(current.outboxIsReliable, isFalse); + expect(current.hasDurabilityFailure, isTrue); + expect( + current.attentionByEntityKey, + contains(const EntitySyncKey.element('page-1', 'element-large')), + ); + expect( + current.queuedByEntityKey, + contains(const EntitySyncKey.element('page-1', 'element-small')), + ); + expect(current.lastError, contains('Nothing was sent')); + expect( + store.load().records.map((record) => record.pending.op.opId), + isNot(contains('oversized-missing-record')), + ); + expect(store.attentionWrites, 1); + }); + + test( + 'a legacy queued oversized record parks without overwrite and survives ' + 'restart', () async { + final store = _OversizedParkingFailureStore(dropBeforeThrow: true); + final oversized = _largeElementPatch( + opId: 'legacy-oversized', + elementId: 'element-large', + ); + const key = EntitySyncKey.element('page-1', 'element-large'); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: key, + pending: PendingOp(op: oversized, clientId: 'stable-client'), + status: DurableOutboxStatus.queued, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + )); + final repository = _RecordingAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: repository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + await notifier.flushNow(); + + var current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(key)); + expect(current.queuedByEntityKey, isEmpty); + expect(repository.calls, isEmpty); + expect(store.attentionWrites, 0); + expect(store.load().records.single.status, DurableOutboxStatus.queued); + + container.dispose(); + container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.flushNow(); + + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(key)); + expect(current.lastError, cloudOperationTooLargeMessage); + expect(repository.calls, isEmpty); + expect(store.attentionWrites, 0); + expect(store.load().records.single.pending.op.opId, oversized.opId); + + final discarded = await notifier.discardRejected({key}); + expect(discarded, {key}); + expect(store.load().records, isEmpty); + }); + + test('Use cloud clears an uncertain oversized first-write failure', + () async { + final store = _OversizedParkingFailureStore(dropBeforeThrow: true); + final container = _cloudQueueContainer( + store: store, + repository: _RecordingAckRepository(), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-large'); + await notifier.enqueue( + _largeElementPatch( + opId: 'oversized-explicit-discard', + elementId: 'element-large', + ), + flushImmediately: false, + ); + await notifier.flushNow(); + + var current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(key)); + expect(current.hasDurabilityFailure, isTrue); + expect(current.outboxIsReliable, isFalse); + expect(store.load().records, isEmpty); + + final discarded = await notifier.discardRejected({key}); + + expect(discarded, {key}); + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.hasDurabilityFailure, isFalse); + expect(current.outboxIsReliable, isTrue); + expect(current.lastError, isNull); + expect(store.load().records, isEmpty); + expect(store.removalAttempts, 1); + }); + + test('Use cloud remains fail-closed when uncertain removal fails', + () async { + final store = _OversizedParkingFailureStore(failRemove: true); + final container = _cloudQueueContainer( + store: store, + repository: _RecordingAckRepository(), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-large'); + await notifier.enqueue( + _largeElementPatch( + opId: 'oversized-failed-discard', + elementId: 'element-large', + ), + flushImmediately: false, + ); + await notifier.flushNow(); + + final discarded = await notifier.discardRejected({key}); + + expect(discarded, isEmpty); + final current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(key)); + expect(current.hasDurabilityFailure, isTrue); + expect(current.outboxIsReliable, isFalse); + expect(current.lastError, contains('could not be removed')); + expect(store.load().records, isEmpty); + expect(store.removalAttempts, 1); + }); + + test('batches split below the conservative argument byte cap', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + for (var index = 0; index < 20; index += 1) { + await notifier.enqueue( + _largeElementPatch( + opId: 'batch-$index', + elementId: 'element-$index', + value: _repeat('x', 820 * 1024), + ), + flushImmediately: false, + ); + } + final allOps = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .values + .map((intent) => intent.pending.op) + .toList(growable: false); + expect( + serializedCloudBatchUtf8Bytes( + strategyPublicId: 'strategy-1', + clientId: container.read(strategyOpQueueProvider).clientId!, + ops: allOps, + ), + greaterThan(maxCloudBatchBytes), + ); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(false); + + await notifier.flushNow(); + await repository.secondCall.future; + for (var index = 0; + index < 10 && + container.read(strategyOpQueueProvider).pending.isNotEmpty; + index += 1) { + await Future.delayed(Duration.zero); + } + + expect(repository.calls, hasLength(2)); + expect(repository.calls.expand((batch) => batch), hasLength(20)); + for (final batch in repository.calls) { + expect( + serializedCloudBatchUtf8Bytes( + strategyPublicId: 'strategy-1', + clientId: container.read(strategyOpQueueProvider).clientId!, + ops: batch, + ), + lessThanOrEqualTo(maxCloudBatchBytes), + ); + } + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, isEmpty); + }); + + test('an oversized same-entity successor is retained in attention', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: repository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + await notifier.enqueue(_elementPatch( + opId: 'safe-predecessor', + value: 'safe', + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + final oversizedSuccessor = _largeElementPatch( + opId: 'oversized-successor', + elementId: 'element-1', + ); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: {key: oversizedSuccessor}, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'safe-predecessor', + revision: 2, + )); + await firstFlush; + for (var index = 0; + index < 10 && + container + .read(strategyOpQueueProvider) + .attentionByEntityKey + .isEmpty; + index += 1) { + await Future.delayed(Duration.zero); + } + + var current = container.read(strategyOpQueueProvider); + expect(repository.calls, hasLength(1)); + expect(current.attentionByEntityKey, contains(key)); + expect(current.successorByEntityKey, isEmpty); + expect(current.lastError, cloudOperationTooLargeMessage); + var durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.payload, oversizedSuccessor.payload); + expect(durable.lastError, cloudOperationTooLargeMessage); + + container.dispose(); + container = _cloudQueueContainer( + store: store, + repository: _RecordingAckRepository(), + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.flushNow(); + + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(key)); + durable = store.load().records.single; + expect(durable.pending.op.payload, oversizedSuccessor.payload); + }); + + test('a valid successor behind an oversized predecessor can land', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _RecordingAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: repository, + ); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + await notifier.enqueue( + ElementAddOp( + opId: 'oversized-predecessor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: { + 'kind': 'drawing', + 'payloadVersion': 1, + 'data': {'encodedPoints': _repeat('界', 310000)}, + }, + sortIndex: 0, + ), + flushImmediately: false, + ); + await notifier.flushNow(); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'valid-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: { + 'kind': 'drawing', + 'payloadVersion': 1, + 'data': {'encodedPoints': 'reduced drawing'}, + }, + sortIndex: 0, + ), + }, + ); + + var durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'oversized-predecessor'); + expect(durable.successorPending?.op.opId, 'valid-successor'); + + container.dispose(); + container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + await notifier.retryRejected(flushImmediately: false); + await notifier.flushNow(); + + expect(repository.calls, hasLength(1)); + expect(repository.calls.single, hasLength(1)); + expect(repository.calls.single.single, isA()); + expect(repository.calls.single.single.payload, { + 'kind': 'drawing', + 'payloadVersion': 1, + 'data': {'encodedPoints': 'reduced drawing'}, + }); + expect( + serializedCloudOperationUtf8Bytes(repository.calls.single.single), + lessThanOrEqualTo(maxCloudOperationBytes), + ); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, isEmpty); + }); + }); + + group('acknowledgement persistence recovery', () { + test('accepted ack remove failure restores the batch for retry', () async { + final store = _OneShotAckFailureStore(failRemove: true); + final container = _cloudQueueContainer( + store: store, + repository: _AckRepository(reject: false), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + await notifier.enqueue(_cloudElementOp(), flushImmediately: false); + await notifier.flushNow(); + + _expectBatchRestored(container, store); + }); + + test('rejected ack put failure restores the batch for retry', () async { + final store = _OneShotAckFailureStore(failAttentionPut: true); + final container = _cloudQueueContainer( + store: store, + repository: _AckRepository(reject: true), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + await notifier.enqueue(_cloudElementOp(), flushImmediately: false); + await notifier.flushNow(); + + _expectBatchRestored(container, store); + }); + }); + + group('page descriptor final intent', () { + test('rebases and sends the final side after an in-flight side patch', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.pageDescriptor('page-1'); + + await notifier.enqueue(_pageSideOp( + opId: 'defense', + isAttack: false, + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _pageSideOp( + opId: 'attack', + isAttack: true, + expectedRevision: 1, + ), + }, + ); + + final duringFirst = container.read(strategyOpQueueProvider); + expect(duringFirst.inFlightByEntityKey[key]!.pending.op.opId, 'defense'); + expect( + duringFirst.successorByEntityKey[key]!.pending.op.payload, + {'isAttack': true}, + ); + final durableDuringFirst = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableDuringFirst.pending.op.opId, 'defense'); + expect(durableDuringFirst.successorPending!.op.payload, { + 'isAttack': true, + }); + + repository.completeFirst(const AppliedOpAck( + opId: 'defense', + revision: 2, + )); + await firstFlush; + await repository.secondStarted.future; + + final promoted = repository.calls[1].single as PagePatchOp; + expect(promoted.payload, {'isAttack': true}); + expect(promoted.expectedPageRevision, 2); + expect(promoted.opId, isNot('attack')); + + repository.completeSecond(AppliedOpAck( + opId: promoted.opId, + revision: 3, + )); + await repository.secondCompleted.future; + await Future.delayed(Duration.zero); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, isEmpty); + }); + + test('restart replays the predecessor before its durable final side', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final firstRepository = _SequencedAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: firstRepository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.pageDescriptor('page-1'); + + await notifier.enqueue(_pageSideOp( + opId: 'defense-before-restart', + isAttack: false, + expectedRevision: 4, + )); + unawaited(notifier.flushNow()); + await firstRepository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _pageSideOp( + opId: 'attack-after-restart', + isAttack: true, + expectedRevision: 4, + ), + }, + ); + container.dispose(); + + final replayRepository = _SequencedAckRepository(); + container = _cloudQueueContainer( + store: store, + repository: replayRepository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await replayRepository.firstStarted.future; + + final replayed = replayRepository.calls.first.single as PagePatchOp; + expect(replayed.opId, 'defense-before-restart'); + expect(replayed.payload, {'isAttack': false}); + expect( + container + .read(strategyOpQueueProvider) + .successorByEntityKey[key]! + .pending + .op + .payload, + {'isAttack': true}, + ); + + replayRepository.completeFirst(const AppliedOpAck( + opId: 'defense-before-restart', + revision: 5, + )); + await replayRepository.secondStarted.future; + final finalSide = replayRepository.calls[1].single as PagePatchOp; + expect(finalSide.payload, {'isAttack': true}); + expect(finalSide.expectedPageRevision, 5); + replayRepository.completeSecond(AppliedOpAck( + opId: finalSide.opId, + revision: 6, + )); + await replayRepository.secondCompleted.future; + }); + }); + + group('same-entity final intent', () { + test('keeps an element successor behind its in-flight predecessor', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(_elementPatch( + opId: 'first-edit', + value: 'first', + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'second-edit', + value: 'second', + expectedRevision: 1, + ), + }, + ); + + final duringFirst = container.read(strategyOpQueueProvider); + expect( + duringFirst.inFlightByEntityKey[key]!.pending.op.opId, + 'first-edit', + ); + expect( + duringFirst.successorByEntityKey[key]!.pending.op.payload, + {'value': 'second'}, + ); + final durableDuringFirst = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableDuringFirst.pending.op.opId, 'first-edit'); + expect( + durableDuringFirst.successorPending!.op.payload, + {'value': 'second'}, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'first-edit', + revision: 2, + )); + await firstFlush; + await repository.secondStarted.future; + + final promoted = repository.calls[1].single as ElementPatchOp; + expect(promoted.payload, {'value': 'second'}); + expect(promoted.expectedElementRevision, 2); + expect(promoted.opId, isNot('second-edit')); + + repository.completeSecond(AppliedOpAck( + opId: promoted.opId, + revision: 3, + )); + await repository.secondCompleted.future; + await Future.delayed(Duration.zero); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, isEmpty); + }); + + test('restart replays an element predecessor before its successor', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final firstRepository = _SequencedAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: firstRepository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(_elementPatch( + opId: 'first-before-restart', + value: 'first', + expectedRevision: 4, + )); + unawaited(notifier.flushNow()); await firstRepository.firstStarted.future; await notifier.syncDesiredOpsForPage( pageId: 'page-1', desiredOpsByEntityKey: { - key: _pageSideOp( - opId: 'attack-after-restart', - isAttack: true, - expectedRevision: 4, + key: _elementPatch( + opId: 'second-after-restart', + value: 'second', + expectedRevision: 4, + ), + }, + ); + container.dispose(); + + final replayRepository = _SequencedAckRepository(); + container = _cloudQueueContainer( + store: store, + repository: replayRepository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await replayRepository.firstStarted.future; + + final replayed = replayRepository.calls.first.single as ElementPatchOp; + expect(replayed.opId, 'first-before-restart'); + expect(replayed.payload, {'value': 'first'}); + expect( + container + .read(strategyOpQueueProvider) + .successorByEntityKey[key]! + .pending + .op + .payload, + {'value': 'second'}, + ); + + replayRepository.completeFirst(const AppliedOpAck( + opId: 'first-before-restart', + revision: 5, + )); + await replayRepository.secondStarted.future; + final finalEdit = replayRepository.calls[1].single as ElementPatchOp; + expect(finalEdit.payload, {'value': 'second'}); + expect(finalEdit.expectedElementRevision, 5); + replayRepository.completeSecond(AppliedOpAck( + opId: finalEdit.opId, + revision: 6, + )); + await replayRepository.secondCompleted.future; + }); + + test('rejected predecessor leaves its element successor in attention', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(_elementPatch( + opId: 'conflicting-first', + value: 'first', + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'retained-second', + value: 'second', + expectedRevision: 1, + ), + }, + ); + + repository.completeFirst(const RejectedOpAck( + opId: 'conflicting-first', + rejectionReason: OpRejectionReason.revisionMismatch, + current: ElementCurrentSnapshot(revision: 2, value: {'value': 'peer'}), + )); + await firstFlush; + await Future.delayed(Duration.zero); + + final conflicted = container.read(strategyOpQueueProvider); + expect(repository.calls, hasLength(1)); + expect(conflicted.attentionByEntityKey, contains(key)); + expect( + conflicted.successorByEntityKey[key]!.pending.op.payload, + {'value': 'second'}, + ); + final durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'conflicting-first'); + expect(durable.successorPending!.op.payload, {'value': 'second'}); + expect(durable.latestServerRevision, 2); + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'retained-second-again', + value: 'second', + expectedRevision: 1, + ), + }, + flushImmediately: true, + ); + await Future.delayed(Duration.zero); + + final reconciled = container.read(strategyOpQueueProvider); + expect(repository.calls, hasLength(1)); + expect(reconciled.attentionByEntityKey, contains(key)); + expect(reconciled.queuedByEntityKey, isEmpty); + expect( + reconciled.successorByEntityKey[key]!.pending.op.payload, + {'value': 'second'}, + ); + final durableAfterReconcile = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableAfterReconcile.status, DurableOutboxStatus.attention); + expect(durableAfterReconcile.pending.op.opId, 'conflicting-first'); + expect( + durableAfterReconcile.successorPending!.op.payload, + {'value': 'second'}, + ); + expect(durableAfterReconcile.latestServerRevision, 2); + + await notifier.retryRejected(flushImmediately: true); + await repository.secondStarted.future; + final retried = repository.calls[1].single as ElementPatchOp; + expect(retried.opId, isNot('retained-second')); + expect(retried.payload, {'value': 'second'}); + expect(retried.expectedElementRevision, 2); + repository.completeSecond(AppliedOpAck( + opId: retried.opId, + revision: 3, + )); + await repository.secondCompleted.future; + }); + + test('promotes an edit after an element add as a patch', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(const ElementAddOp( + opId: 'element-add-in-flight', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'first'}, + sortIndex: 0, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'element-add-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'second'}, + sortIndex: 0, + ), + }, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'element-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalEdit = repository.calls[1].single as ElementPatchOp; + expect(finalEdit.payload, {'value': 'second'}); + expect(finalEdit.expectedElementRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalEdit.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); + + test('promotes an edit after a lineup add as a patch', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.lineup('page-1', 'lineup-1'); + + await notifier.enqueue(const LineupAddOp( + opId: 'lineup-add-in-flight', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + payload: {'value': 'first'}, + sortIndex: 0, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const LineupAddOp( + opId: 'lineup-add-successor', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + payload: {'value': 'second'}, + sortIndex: 0, ), }, ); - container.dispose(); - final replayRepository = _SequencedAckRepository(); - container = _cloudQueueContainer( + repository.completeFirst(const AppliedOpAck( + opId: 'lineup-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalEdit = repository.calls[1].single as LineupPatchOp; + expect(finalEdit.payload, {'value': 'second'}); + expect(finalEdit.expectedLineupRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalEdit.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); + + test('keeps a restore add after an accepted element delete', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( store: store, - repository: replayRepository, + repository: repository, ); addTearDown(container.dispose); - notifier = container.read(strategyOpQueueProvider.notifier) + final notifier = container.read(strategyOpQueueProvider.notifier) ..setActiveStrategy('strategy-1', accountId: 'account-a'); - await replayRepository.firstStarted.future; + const key = EntitySyncKey.element('page-1', 'element-1'); - final replayed = replayRepository.calls.first.single as PagePatchOp; - expect(replayed.opId, 'defense-before-restart'); - expect(replayed.payload, {'isAttack': false}); - expect( - container - .read(strategyOpQueueProvider) - .successorByEntityKey[key]! - .pending - .op - .payload, - {'isAttack': true}, + await notifier.enqueue(const ElementDeleteOp( + opId: 'element-delete-in-flight', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + expectedElementRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'element-restore-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'restored'}, + sortIndex: 0, + expectedElementRevision: 1, + ), + }, ); - replayRepository.completeFirst(const AppliedOpAck( - opId: 'defense-before-restart', - revision: 5, + repository.completeFirst(const AppliedOpAck( + opId: 'element-delete-in-flight', + revision: 2, )); - await replayRepository.secondStarted.future; - final finalSide = replayRepository.calls[1].single as PagePatchOp; - expect(finalSide.payload, {'isAttack': true}); - expect(finalSide.expectedPageRevision, 5); - replayRepository.completeSecond(AppliedOpAck( - opId: finalSide.opId, - revision: 6, + await firstFlush; + await repository.secondStarted.future; + + final restore = repository.calls[1].single as ElementAddOp; + expect(restore.payload, {'value': 'restored'}); + expect(restore.expectedElementRevision, 2); + repository.completeSecond(AppliedOpAck( + opId: restore.opId, + revision: 3, )); - await replayRepository.secondCompleted.future; + await repository.secondCompleted.future; + }); + + test('keeps a final element delete behind an in-flight add', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(const ElementAddOp( + opId: 'element-add-in-flight', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'first'}, + sortIndex: 0, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'element-add-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'second'}, + sortIndex: 0, + ), + }, + ); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementDeleteOp( + opId: 'element-delete-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + expectedElementRevision: 0, + ), + }, + ); + + final durableBeforeAck = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableBeforeAck.pending.op.opId, 'element-add-in-flight'); + expect(durableBeforeAck.successorPending!.op, isA()); + + repository.completeFirst(const AppliedOpAck( + opId: 'element-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalDelete = repository.calls[1].single as ElementDeleteOp; + expect(finalDelete.expectedElementRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalDelete.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); + + test('keeps a final lineup delete behind an in-flight add', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.lineup('page-1', 'lineup-1'); + + await notifier.enqueue(const LineupAddOp( + opId: 'lineup-add-in-flight', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + payload: {'value': 'first'}, + sortIndex: 0, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const LineupAddOp( + opId: 'lineup-add-successor', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + payload: {'value': 'second'}, + sortIndex: 0, + ), + }, + ); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const LineupDeleteOp( + opId: 'lineup-delete-successor', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + expectedLineupRevision: 0, + ), + }, + ); + + final durableBeforeAck = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableBeforeAck.pending.op.opId, 'lineup-add-in-flight'); + expect(durableBeforeAck.successorPending!.op, isA()); + + repository.completeFirst(const AppliedOpAck( + opId: 'lineup-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalDelete = repository.calls[1].single as LineupDeleteOp; + expect(finalDelete.expectedLineupRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalDelete.opId, + revision: 2, + )); + await repository.secondCompleted.future; }); }); } @@ -807,6 +2238,36 @@ PagePatchOp _pageSideOp({ ); } +ElementPatchOp _elementPatch({ + required String opId, + required String value, + required int expectedRevision, +}) { + return ElementPatchOp( + opId: opId, + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': value}, + expectedElementRevision: expectedRevision, + ); +} + +ElementPatchOp _largeElementPatch({ + required String opId, + required String elementId, + String? value, +}) { + return ElementPatchOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-1', + payload: {'value': value ?? _repeat('界', 310000)}, + expectedElementRevision: 1, + ); +} + +String _repeat(String value, int count) => List.filled(count, value).join(); + void _expectBatchRestored( ProviderContainer container, MemoryDurableStrategyOutboxStore store, @@ -865,6 +2326,26 @@ class _AckRepository extends ConvexStrategyRepository { } } +class _RecordingAckRepository extends ConvexStrategyRepository { + _RecordingAckRepository() : super(IcarusConvexApi(_UnusedTransport())); + + final List> calls = []; + final secondCall = Completer(); + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) async { + calls.add(List.from(ops)); + if (calls.length == 2 && !secondCall.isCompleted) secondCall.complete(); + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + class _SequencedAckRepository extends ConvexStrategyRepository { _SequencedAckRepository() : super(IcarusConvexApi(_UnusedTransport())); @@ -925,6 +2406,62 @@ class _OneShotAckFailureStore extends MemoryDurableStrategyOutboxStore { } } +class _FirstPutFailureStore extends MemoryDurableStrategyOutboxStore { + var failNextPut = true; + + @override + Future put(DurableOutboxRecord record) async { + if (failNextPut) { + failNextPut = false; + throw StateError('initial write failed'); + } + await super.put(record); + } +} + +class _OversizedParkingFailureStore + extends MemoryDurableStrategyOutboxStore { + _OversizedParkingFailureStore({ + this.dropBeforeThrow = false, + this.failRemove = false, + }); + + final bool dropBeforeThrow; + final bool failRemove; + var attentionWrites = 0; + var removalAttempts = 0; + + @override + Future put(DurableOutboxRecord record) async { + if (record.status == DurableOutboxStatus.attention && + cloudOperationExceedsPolicy(record.pending.op)) { + attentionWrites += 1; + if (dropBeforeThrow) values.remove(record.storageKey); + throw StateError('oversized attention write failed'); + } + await super.put(record); + } + + @override + Future remove(String storageKey) async { + removalAttempts += 1; + if (failRemove) throw StateError('uncertain removal failed'); + await super.remove(storageKey); + } +} + +class _FailingSelectedRemovalStore extends MemoryDurableStrategyOutboxStore { + String? failStorageKey; + + @override + Future remove(String storageKey) async { + if (storageKey == failStorageKey) { + throw StateError('selected removal failed'); + } + await super.remove(storageKey); + } +} + class _UnusedTransport implements ConvexTransport { @override Future action(String name, ConvexObject args) => diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 65f817dd..8f951f03 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/line_provider.dart'; @@ -15,6 +16,7 @@ import 'package:icarus/const/transition_data.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; @@ -28,6 +30,7 @@ import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/transition_provider.dart' hide PageTransitionState; +import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -111,6 +114,18 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { final queued = Map.from( state.queuedByEntityKey, ); + final successors = Map.from( + state.successorByEntityKey, + ); + if (desiredOp != null && + state.attentionByEntityKey.containsKey(entityKey)) { + successors[entityKey] = QueuedEntityIntent( + entityKey: entityKey, + pending: PendingOp(op: desiredOp, clientId: 'test-client'), + ); + state = state.copyWith(successorByEntityKey: successors); + return; + } if (desiredOp == null) { queued.remove(entityKey); } else { @@ -132,17 +147,30 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { final queued = Map.from( state.queuedByEntityKey, ); + final successors = Map.from( + state.successorByEntityKey, + ); if (clearMissing) { queued.removeWhere((key, _) => key.pageId == pageId && !desiredOpsByEntityKey.containsKey(key)); } for (final entry in desiredOpsByEntityKey.entries) { + if (state.attentionByEntityKey.containsKey(entry.key)) { + successors[entry.key] = QueuedEntityIntent( + entityKey: entry.key, + pending: PendingOp(op: entry.value, clientId: 'test-client'), + ); + continue; + } queued[entry.key] = QueuedEntityIntent( entityKey: entry.key, pending: PendingOp(op: entry.value, clientId: 'test-client'), ); } - state = state.copyWith(queuedByEntityKey: queued); + state = state.copyWith( + queuedByEntityKey: queued, + successorByEntityKey: successors, + ); } @override @@ -151,6 +179,29 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { if (blockFlush) await Completer().future; } + @override + Future> discardRejected( + Set entityKeys, + ) async { + final attention = Map.from( + state.attentionByEntityKey, + ); + final successors = Map.from( + state.successorByEntityKey, + ); + final discarded = attention.keys.toSet().intersection(entityKeys); + for (final key in discarded) { + attention.remove(key); + successors.remove(key); + } + state = state.copyWith( + attentionByEntityKey: attention, + successorByEntityKey: successors, + clearError: attention.isEmpty, + ); + return discarded; + } + void reject(StrategyOp op) { final key = EntitySyncKey.forStrategyOp(op)!; final pending = PendingOp(op: op, clientId: 'test-client'); @@ -184,6 +235,12 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { }, ); } + + void clearInFlight() { + state = state.copyWith( + inFlightByEntityKey: const {}, + ); + } } Future _settle() async { @@ -210,9 +267,12 @@ RemoteElement _textElement( String id, String value, { int revision = 1, + int sortIndex = 0, + bool worldSized = false, bool deleted = false, }) { final text = PlacedText(id: id, position: const Offset(10, 20))..text = value; + if (worldSized) text.markSizeAsWorld(); final payload = Map.from(text.toJson()) ..['elementType'] = 'text'; return RemoteElement( @@ -221,7 +281,7 @@ RemoteElement _textElement( pagePublicId: pageId, elementType: 'text', payload: cloudElementPayload(kind: 'text', data: payload), - sortIndex: 0, + sortIndex: sortIndex, revision: revision, deleted: deleted, ); @@ -312,6 +372,7 @@ RemoteEditorSnapshot _editorSnapshot({ int shellRevision = 1, String? mapData, String? themeProfileId, + String role = 'owner', }) { final now = DateTime.utc(2026); return RemoteEditorSnapshot( @@ -324,6 +385,7 @@ RemoteEditorSnapshot _editorSnapshot({ createdAt: now, updatedAt: now, themeProfileId: themeProfileId, + role: role, ), pages: pages, ), @@ -566,6 +628,7 @@ void main() { container.read(activePageLiveSyncProvider.notifier).markPageHydrated( strategyPublicId: 'cloud-strategy', pageId: page.publicId, + snapshot: container.read(remoteEditorSnapshotProvider).requireValue!, ); final desired = @@ -790,6 +853,249 @@ void main() { expect(container.read(strategyConflictProvider).single.opId, op.opId); }); + test( + 'using cloud after a conflict replaces the canvas without resubmitting it', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement(page.publicId, 'text-page-1', 'server-before', + worldSized: true), + ], + ), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + const textId = 'text-page-1'; + const key = EntitySyncKey.element('page-1', textId); + container.read(textProvider.notifier).commitText( + textId, + 'local-losing-intent', + ); + await _settle(); + final rejectedOp = container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op) + .firstWhere((op) => op.entityPublicId == textId); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement(page.publicId, textId, 'server-winner', + worldSized: true), + ], + contentRevision: 2, + ), + )); + queue.reject(rejectedOp); + await _settle(); + container + .read(textDraftProvider.notifier) + .setDraft(textId, 'draft-in-progress'); + container + .read(textDraftProvider.notifier) + .setDraft('unrelated-text', 'keep-this-draft'); + + final resolved = await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _settle(); + + expect(resolved, isTrue); + expect(container.read(textProvider).single.text, 'server-winner'); + expect(container.read(textDraftProvider), { + 'unrelated-text': 'keep-this-draft', + }); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + isNot(contains(key)), + ); + expect( + container.read(activePageLiveSyncProvider).overlayByEntityKey, + isNot(contains(key)), + ); + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op.entityPublicId), + isNot(contains(textId)), + ); + + container.read(textDraftProvider.notifier).clearDraft(textId); + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + expect(desired, isNotNull); + expect(desired, isNot(contains(key))); + await queue.syncDesiredOpsForPage( + pageId: page.publicId, + desiredOpsByEntityKey: desired!, + flushImmediately: false, + ); + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op.entityPublicId), + isNot(contains(textId)), + ); + }); + + test('using cloud with no remaining attention is a silent no-op', () async { + final page = _page('page-1', 0); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'remote'), + )), + queue: _FakeStrategyOpQueueNotifier(), + ); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + expect(await session.useCloudVersionsForRejected(), isTrue); + }); + + test('failed cloud hydration keeps the rejected work available', () async { + final page = _page('page-1', 0); + const textId = 'text-page-1'; + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container.read(textProvider.notifier).commitText(textId, 'local-edit'); + await _settle(); + final rejectedOp = container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op) + .firstWhere((op) => op.entityPublicId == textId); + final malformedLineup = RemoteLineup( + publicId: 'bad-lineup', + strategyPublicId: 'cloud-strategy', + pagePublicId: page.publicId, + payload: const { + 'kind': 'lineupGroup', + 'payloadVersion': 1, + 'data': {'broken': true}, + }, + sortIndex: 0, + revision: 1, + deleted: false, + ); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement(page.publicId, textId, 'server-winner'), + ], + lineups: [malformedLineup], + ), + )); + queue.reject(rejectedOp); + await _settle(); + + await expectLater( + session.useCloudVersionsForRejected(), + throwsA(isA()), + ); + + final key = EntitySyncKey.element(page.publicId, textId); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + contains(key), + ); + expect( + container.read(activePageLiveSyncProvider).overlayByEntityKey, + contains(key), + ); + }); + + test('using cloud for a strategy conflict restores remote map and theme', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + shellRevision: 3, + mapData: Maps.mapNames[MapValue.haven], + themeProfileId: 'remote-theme', + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(mapProvider.notifier).updateMap(MapValue.ascent); + container.read(strategyThemeProvider.notifier).setProfile('local-theme'); + await _settle(); + final rejectedOp = container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op) + .firstWhere((op) => op.entityType == StrategyOpEntityType.strategy); + queue.reject(rejectedOp); + await _settle(); + + final resolved = await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _settle(); + + expect(resolved, isTrue); + expect(container.read(mapProvider).currentMap, MapValue.haven); + expect(container.read(strategyThemeProvider).profileId, 'remote-theme'); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + isNot(contains(const EntitySyncKey.strategy())), + ); + + await container + .read(strategyProvider.notifier) + .notifyCloudStrategyMutation(flushImmediately: false); + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op.entityType), + isNot(contains(StrategyOpEntityType.strategy)), + ); + }); + test('inactive page shell update does not rehydrate the active canvas', () async { final pageOne = _page('page-1', 0); @@ -873,6 +1179,7 @@ void main() { container.read(activePageLiveSyncProvider.notifier).markPageHydrated( strategyPublicId: 'cloud-strategy', pageId: page.publicId, + snapshot: container.read(remoteEditorSnapshotProvider).requireValue!, ); final desired = @@ -892,6 +1199,267 @@ void main() { ); }); + test('final text is emitted while an earlier edit is in flight', () async { + final page = _page('page-1', 0); + const textId = 'text-page-1'; + final remoteText = _textElement( + page.publicId, + textId, + 'before', + worldSized: true, + ); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [remoteText]), + )), + queue: queue, + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + const key = EntitySyncKey.element('page-1', textId); + final firstEdit = ElementPatchOp( + opId: 'first-edit-in-flight', + elementPublicId: textId, + pagePublicId: page.publicId, + payload: _textElement( + page.publicId, + textId, + 'first-edit', + worldSized: true, + ).payload, + sortIndex: 0, + expectedElementRevision: 1, + ); + queue.holdInFlight(key, firstEdit); + container.read(textDraftProvider.notifier).setDraft(textId, 'final-edit'); + + final beforeAck = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + )![key] as ElementPatchOp; + expect(beforeAck.payload.toString(), contains('final-edit')); + expect(beforeAck.expectedElementRevision, 1); + + container.read(activePageLiveSyncProvider.notifier).recordAckBatch([ + AckedEntityIntent( + entityKey: key, + op: firstEdit, + ack: const AppliedOpAck( + opId: 'first-edit-in-flight', + revision: 2, + ), + ), + ]); + queue.clearInFlight(); + + final afterAck = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + )![key] as ElementPatchOp; + expect(afterAck.payload.toString(), contains('final-edit')); + expect(afterAck.expectedElementRevision, 2); + }); + + test('a final delete is emitted behind an in-flight local add', () async { + final page = _page('page-1', 0); + const textId = 'new-local-text'; + const key = EntitySyncKey.element('page-1', textId); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _syncContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )), + queue: queue, + ); + final sync = container.read(activePageLiveSyncProvider.notifier); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + snapshot: container.read(remoteEditorSnapshotProvider).requireValue!, + ); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: textId, position: const Offset(10, 20)) + ..text = 'first' + ..markSizeAsWorld(), + ]); + + final firstDesired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + final add = firstDesired![key] as ElementAddOp; + queue.holdInFlight(key, add); + container.read(textProvider.notifier).removeText(textId); + + final finalDesired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final delete = finalDesired![key] as ElementDeleteOp; + expect(delete.expectedElementRevision, 0); + }); + + test('restart retains a queued add missing from canvas and remote', () async { + final page = _page('page-1', 0); + const textId = 'queued-before-restart'; + const key = EntitySyncKey.element('page-1', textId); + final add = ElementAddOp( + opId: 'add-before-restart', + elementPublicId: textId, + pagePublicId: page.publicId, + payload: _textElement(page.publicId, textId, 'unsent').payload, + sortIndex: 0, + ); + final store = MemoryDurableStrategyOutboxStore(); + final firstContainer = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), + ]); + firstContainer + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final firstQueue = firstContainer.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('cloud-strategy', accountId: 'account-a'); + await firstQueue.enqueue(add, flushImmediately: false); + expect(store.load().records.single.pending.op.opId, 'add-before-restart'); + firstContainer.dispose(); + + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )); + final restarted = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), + remoteEditorSnapshotProvider.overrideWith(() => remote), + ]); + addTearDown(restarted.dispose); + restarted + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final restartedQueue = restarted.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('cloud-strategy', accountId: 'account-a'); + await restarted.read(remoteEditorSnapshotProvider.future); + final sync = restarted.read(activePageLiveSyncProvider.notifier); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + snapshot: restarted.read(remoteEditorSnapshotProvider).requireValue!, + ); + + final desired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final retained = desired![key] as ElementAddOp; + expect(retained.opId, 'add-before-restart'); + expect(retained.payload, add.payload); + await restartedQueue.syncDesiredOpsForPage( + pageId: page.publicId, + desiredOpsByEntityKey: desired, + flushImmediately: false, + ); + expect( + restarted + .read(strategyOpQueueProvider) + .queuedByEntityKey[key]! + .pending + .op, + isA(), + ); + final durable = store.load().records.singleWhere( + (record) => record.entityKey == key, + ); + expect(durable.pending.op.opId, 'add-before-restart'); + }); + + test('hydration keeps the exact snapshot used to load the canvas', () async { + const textId = 'text-page-1'; + final hydratedPage = _page('page-1', 0); + final hydratedSnapshot = _editorSnapshot( + pages: [hydratedPage], + activePage: _pageSnapshot( + hydratedPage, + elements: [ + _textElement( + hydratedPage.publicId, + textId, + 'hydrated-value', + worldSized: true, + ), + ], + ), + ); + final newerPage = _page('page-1', 0, revision: 2); + final container = await _syncContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [newerPage], + activePage: _pageSnapshot( + newerPage, + elements: [ + _textElement( + newerPage.publicId, + textId, + 'newer-remote-value', + revision: 2, + worldSized: true, + ), + ], + ), + )), + queue: _FakeStrategyOpQueueNotifier(), + ); + final hydratedText = PlacedText( + id: textId, + position: const Offset(10, 20), + ) + ..text = 'hydrated-value' + ..markSizeAsWorld(); + container.read(textProvider.notifier).fromHive([hydratedText]); + + final sync = container.read(activePageLiveSyncProvider.notifier); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: hydratedPage.publicId, + snapshot: hydratedSnapshot, + ); + container.read(textDraftProvider.notifier).setDraft(textId, 'local-edit'); + + final desired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: hydratedPage.publicId, + ); + + final op = desired![EntitySyncKey.element(hydratedPage.publicId, textId)] + as ElementPatchOp; + expect(op.payload.toString(), contains('local-edit')); + expect(op.expectedElementRevision, 1); + }); + test('side switch authors exactly one Page descriptor operation', () async { final page = _page('page-1', 0, revision: 11); final container = await _syncContainer( @@ -911,6 +1479,7 @@ void main() { container.read(activePageLiveSyncProvider.notifier).markPageHydrated( strategyPublicId: 'cloud-strategy', pageId: page.publicId, + snapshot: container.read(remoteEditorSnapshotProvider).requireValue!, ); container.read(mapProvider.notifier).switchSide(); @@ -1100,6 +1669,98 @@ void main() { expect(container.read(strategyOpQueueProvider).pending, isNotEmpty); }); + test( + 'collaborator edits stay based on the page this client actually hydrated', + () async { + final page = _page('page-1', 0); + const localTextId = 'local-text'; + const collaboratorTextId = 'collaborator-text'; + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement( + page.publicId, + localTextId, + 'shared-before', + worldSized: true, + ), + _textElement( + page.publicId, + collaboratorTextId, + 'collaborator-before', + sortIndex: 1, + worldSized: true, + ), + ], + ), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(textProvider.notifier).commitText( + localTextId, + 'this-client-edit', + ); + await _settle(); + + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement( + page.publicId, + localTextId, + 'collaborator-winner', + revision: 2, + worldSized: true, + ), + _textElement( + page.publicId, + collaboratorTextId, + 'collaborator-after', + revision: 2, + sortIndex: 1, + worldSized: true, + ), + ], + ), + )); + await _settle(); + + expect( + container + .read(textProvider) + .firstWhere((text) => text.id == collaboratorTextId) + .text, + 'collaborator-before', + ); + + await session.flushCurrentPage(); + + final elementOps = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .where((entry) => entry.key.kind == EntitySyncKeyKind.element) + .toList(growable: false); + expect(elementOps, hasLength(1)); + expect(elementOps.single.key.entityId, localTextId); + expect(elementOps.single.value.pending.op.expectedRevision, 1); + expect( + elementOps.single.value.pending.op.payload.toString(), + contains('this-client-edit'), + ); + }); + test('local mode page switching keeps its shipped Hive shape', () async { final box = await _openStrategyBox(); final now = DateTime.utc(2026); diff --git a/test/unsaved_strategy_guard_test.dart b/test/unsaved_strategy_guard_test.dart index 10331e44..b59f9114 100644 --- a/test/unsaved_strategy_guard_test.dart +++ b/test/unsaved_strategy_guard_test.dart @@ -4,12 +4,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/in_app_debug_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -79,6 +87,64 @@ class _ThrowingSaveStrategyProvider extends StrategyProvider { } } +class _GuardAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +class _GuardOpQueue extends StrategyOpQueueNotifier { + _GuardOpQueue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; + + StrategyOpQueueState get currentState => state; + + void settle() { + state = StrategyOpQueueState( + accountId: initialState.accountId, + strategyPublicId: initialState.strategyPublicId, + clientId: initialState.clientId, + durableLoaded: true, + ); + } +} + +class _GuardMediaQueue extends CloudMediaUploadQueueNotifier { + _GuardMediaQueue([ + this.initialState = const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ), + ]); + + final CloudMediaUploadQueueState initialState; + + @override + CloudMediaUploadQueueState build() => initialState; +} + +const _guardEntityKey = EntitySyncKey.strategy(); +const _guardPendingIntent = QueuedEntityIntent( + entityKey: _guardEntityKey, + pending: PendingOp( + op: StrategyPatchOp( + opId: 'guard-op', + payload: {'name': 'pending'}, + expectedStrategyRevision: 1, + ), + clientId: 'guard-client', + ), +); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); CoordinateSystem(playAreaSize: const Size(1920, 1080)); @@ -506,6 +572,407 @@ void main() { expect(logs.last.message, 'Failed to save strategy before leaving.'); expect(logs.last.source, 'guard-test-error'); }); + + testWidgets('offline durable work can leave and remains queued', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final opQueue = _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + queuedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + durableLoaded: true, + lastError: 'Cloud connection is offline.', + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => opQueue), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + var continueCalls = 0; + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-offline', + onContinue: () async { + continueCalls++; + }, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave Anyway'), findsOneWidget); + expect( + find.textContaining('have not reached the cloud'), + findsOneWidget, + ); + expect(opQueue.currentState.pending, hasLength(1)); + await tester.tap(find.text('Leave Anyway')); + await tester.pumpAndSettle(); + + expect(await guardFuture, isTrue); + expect(continueCalls, 1); + expect(opQueue.currentState.pending, hasLength(1)); + }); + + testWidgets('paused viewer work has a leave-anyway path', (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final opQueue = _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + pausedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + durableLoaded: true, + lastError: 'This viewer edit cannot be retried automatically.', + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => opQueue), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(true), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + var continueCalls = 0; + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-viewer', + onContinue: () async { + continueCalls++; + }, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave Anyway'), findsOneWidget); + await tester.tap(find.text('Leave Anyway')); + await tester.pumpAndSettle(); + + expect(await guardFuture, isTrue); + expect(continueCalls, 1); + expect(opQueue.currentState.pausedByEntityKey, isNotEmpty); + }); + + testWidgets('non-durable cloud work cannot leave', (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final opQueue = _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + queuedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + durableLoaded: true, + hasDurabilityFailure: true, + lastError: 'Cloud work could not be saved to the durable outbox.', + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => opQueue), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(true), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-nondurable', + onContinue: () async {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave Anyway'), findsNothing); + expect(find.text('Stay Here'), findsOneWidget); + await tester.tap(find.text('Stay Here')); + await tester.pumpAndSettle(); + expect(await guardFuture, isFalse); + }); + + testWidgets('an unreliable media outbox can leave without deleting work', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final mediaQueue = _GuardMediaQueue( + CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'image-a', + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + assetPublicId: 'image-a', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ], + isProcessing: false, + loadIssues: const [ + DurableCloudMediaOutboxLoadIssue( + storageKey: 'account-a|unreadable-image', + error: 'bad record', + ), + ], + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith( + () => _GuardOpQueue( + const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + durableLoaded: true, + ), + ), + ), + cloudMediaUploadQueueProvider.overrideWith(() => mediaQueue), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + var continueCalls = 0; + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-unreliable-media', + onContinue: () async { + continueCalls += 1; + }, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave Anyway'), findsOneWidget); + expect(find.textContaining('will not delete'), findsOneWidget); + await tester.tap(find.text('Leave Anyway')); + await tester.pumpAndSettle(); + expect(await guardFuture, isTrue); + expect(continueCalls, 1); + }); + + testWidgets('media without a durable strategy reference cannot leave', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final mediaQueue = _GuardMediaQueue( + CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'staged-image', + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + assetPublicId: 'staged-image', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ], + isProcessing: false, + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith( + () => _GuardOpQueue( + const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + durableLoaded: true, + ), + ), + ), + cloudMediaUploadQueueProvider.overrideWith(() => mediaQueue), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-staged-media', + onContinue: () async {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave Anyway'), findsNothing); + await tester.tap(find.text('Stay Here')); + await tester.pumpAndSettle(); + expect(await guardFuture, isFalse); + }); + + testWidgets('cloud exit stages an active text draft before leaving', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith( + () => _GuardOpQueue( + const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + durableLoaded: true, + ), + ), + ), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ], + ); + addTearDown(container.dispose); + container + .read(textDraftProvider.notifier) + .setDraft('text-a', 'active cloud draft'); + await pumpHarness(tester); + + var continueCalls = 0; + final result = await guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-draft', + onContinue: () async { + continueCalls++; + }, + ); + await tester.pumpAndSettle(); + + expect(result, isTrue); + expect(continueCalls, 1); + expect(notifier.forceSaveCalls, 1); + expect(container.read(textDraftProvider), isEmpty); + }); + + testWidgets('active cloud flush completes and exits without a dialog', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final opQueue = _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + queuedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + durableLoaded: true, + isFlushing: true, + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => opQueue), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(true), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + var continueCalls = 0; + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-flush', + onContinue: () async { + continueCalls++; + }, + ); + await tester.pump(); + opQueue.settle(); + await tester.pump(const Duration(milliseconds: 150)); + + expect(await guardFuture, isTrue); + expect(continueCalls, 1); + expect(find.text('Cloud sync pending'), findsNothing); + }); }); } diff --git a/test/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart index 74276ec4..c82daaa8 100644 --- a/test/widgets/cloud_beta_automation_semantics_test.dart +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -5,10 +5,12 @@ import 'package:flutter/semantics.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; void main() { testWidgets('shared text fields expose live editable semantics', @@ -158,6 +160,34 @@ void main() { expect(find.byType(AuthDialog), findsOneWidget); }); + + testWidgets('library account action uses guarded sign out', (tester) async { + var requests = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(_SignedInAuthProvider.new), + guardedSignOutRequestProvider.overrideWithValue((context) async { + requests += 1; + return true; + }), + ], + child: const ShadApp( + home: Scaffold( + body: SizedBox( + width: 220, + height: 800, + child: LibraryNavigationRail(), + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('library-account-action'))); + await tester.pump(); + expect(requests, 1); + }); } Semantics _semantics(String label) { @@ -212,3 +242,20 @@ class _SignedOutAuthProvider extends AuthProvider { user: null, ); } + +class _SignedInAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {'full_name': 'Coach'}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} diff --git a/test/widgets/cloud_library_action_dialogs_test.dart b/test/widgets/cloud_library_action_dialogs_test.dart new file mode 100644 index 00000000..cca44942 --- /dev/null +++ b/test/widgets/cloud_library_action_dialogs_test.dart @@ -0,0 +1,392 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/convex_client.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart'; +import 'package:icarus/widgets/folder_edit_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + testWidgets('folder edit failure stays open, is safe, and can retry', + (tester) async { + final firstAttempt = Completer(); + final folderProvider = _ControlledFolderProvider() + ..editResults.add(firstAttempt.future) + ..editResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [ + _folderProviderOverride(folderProvider), + ], + dialog: FolderEditDialog(folder: _folder()), + ); + + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.pump(); + expect(folderProvider.editCalls, 1); + expect(find.text('Saving...'), findsOneWidget); + + firstAttempt.complete( + CloudLibraryActionResult.failed( + "Couldn't update this cloud folder. Try again.", + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(FolderEditDialog), findsOneWidget); + expect( + find.text("Couldn't update this cloud folder. Try again."), + findsOneWidget, + ); + expect(find.text(_secret), findsNothing); + + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.pumpAndSettle(); + expect(folderProvider.editCalls, 2); + expect(find.byType(FolderEditDialog), findsNothing); + }); + + testWidgets('folder delete failure stays open, is safe, and can retry', + (tester) async { + final firstAttempt = Completer(); + final folderProvider = _ControlledFolderProvider() + ..deleteResults.add(firstAttempt.future) + ..deleteResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [_folderProviderOverride(folderProvider)], + dialog: DeleteFolderAlertDialog( + folder: _folder(), + workspace: LibraryWorkspace.cloud, + ), + ); + + await tester.tap(find.byKey(const ValueKey('delete-folder-confirm'))); + await tester.tap(find.byKey(const ValueKey('delete-folder-confirm'))); + await tester.pump(); + expect(folderProvider.deleteCalls, 1); + expect(find.text('Deleting...'), findsOneWidget); + + firstAttempt.complete( + CloudLibraryActionResult.failed( + "Couldn't delete this cloud folder. Try again.", + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DeleteFolderAlertDialog), findsOneWidget); + expect( + find.text("Couldn't delete this cloud folder. Try again."), + findsOneWidget, + ); + expect(find.text(_secret), findsNothing); + + await tester.tap(find.byKey(const ValueKey('delete-folder-confirm'))); + await tester.pumpAndSettle(); + expect(folderProvider.deleteCalls, 2); + expect(find.byType(DeleteFolderAlertDialog), findsNothing); + }); + + testWidgets('strategy delete failure stays open, is safe, and can retry', + (tester) async { + final firstAttempt = Completer(); + final strategyProvider = _ControlledStrategyProvider() + ..deleteResults.add(firstAttempt.future) + ..deleteResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [_strategyProviderOverride(strategyProvider)], + dialog: const DeleteStrategyAlertDialog( + strategyID: 'strategy-1', + name: 'A Split', + source: StrategySource.cloud, + ), + ); + + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.pump(); + expect(strategyProvider.deleteCalls, 1); + expect(find.text('Deleting...'), findsOneWidget); + + firstAttempt.complete( + CloudLibraryActionResult.failed( + "Couldn't delete this cloud strategy. Try again.", + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DeleteStrategyAlertDialog), findsOneWidget); + expect( + find.text("Couldn't delete this cloud strategy. Try again."), + findsOneWidget, + ); + expect(find.text(_secret), findsNothing); + + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.pumpAndSettle(); + expect(strategyProvider.deleteCalls, 2); + expect(find.byType(DeleteStrategyAlertDialog), findsNothing); + }); + + testWidgets('thrown strategy delete resets the dialog and can retry', + (tester) async { + final firstAttempt = Completer(); + final strategyProvider = _ControlledStrategyProvider() + ..deleteResults.add(firstAttempt.future) + ..deleteResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [_strategyProviderOverride(strategyProvider)], + dialog: const DeleteStrategyAlertDialog( + strategyID: 'strategy-1', + name: 'A Split', + source: StrategySource.local, + ), + ); + + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + firstAttempt.completeError(StateError(_secret)); + await tester.pumpAndSettle(); + + expect( + find.text("Couldn't delete this strategy. Try again."), findsOneWidget); + expect(find.text(_secret), findsNothing); + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.pumpAndSettle(); + expect(strategyProvider.deleteCalls, 2); + expect(find.byType(DeleteStrategyAlertDialog), findsNothing); + }); + + testWidgets('cloud export failure reports one generic message', + (tester) async { + final messages = []; + var exportCalls = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + cloudStrategyExporterProvider.overrideWithValue((_) async { + exportCalls += 1; + throw StateError(_secret); + }), + cloudLibraryActionReporterProvider.overrideWithValue( + CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ), + ), + authProvider.overrideWith(_ReadyAuthProvider.new), + ], + child: const ShadApp( + home: Scaffold(body: _CloudExportInvoker()), + ), + ), + ); + + await tester.tap(find.text('Export cloud strategy')); + await tester.pumpAndSettle(); + + expect(exportCalls, 1); + expect(messages, ["Couldn't export this cloud strategy. Try again."]); + expect(messages.single, isNot(contains(_secret))); + }); + + testWidgets('cancelled cloud export stays silent', (tester) async { + final messages = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + cloudStrategyExporterProvider.overrideWithValue((_) async => false), + cloudLibraryActionReporterProvider.overrideWithValue( + CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ), + ), + authProvider.overrideWith(_ReadyAuthProvider.new), + ], + child: const ShadApp( + home: Scaffold(body: _CloudExportInvoker()), + ), + ), + ); + + await tester.tap(find.text('Export cloud strategy')); + await tester.pumpAndSettle(); + + expect(messages, isEmpty); + }); + + test('auth failures use the incident path without a generic message', + () async { + final messages = []; + var authReports = 0; + final reporter = CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ); + + final result = await reporter.run( + action: () async => throw const ConvexClientFunctionError( + rawCode: 'UNAUTHENTICATED', + message: 'Authentication required', + data: null, + ), + source: 'test:auth', + failureMessage: 'This must not be shown.', + showFailureMessage: true, + reportAuthenticationFailure: (_, __) async => authReports += 1, + ); + + expect(result.status, CloudLibraryActionStatus.authenticationRequired); + expect(result.userMessage, 'Reconnect to Icarus Cloud, then try again.'); + expect(authReports, 1); + expect(messages, isEmpty); + }); +} + +const _secret = 'Bearer super-secret-backend-detail'; + +Override _folderProviderOverride(_ControlledFolderProvider notifier) => + folderProvider.overrideWith(() => notifier); + +Override _strategyProviderOverride(_ControlledStrategyProvider notifier) => + strategyProvider.overrideWith(() => notifier); + +Future _pumpDialogLauncher( + WidgetTester tester, { + required List overrides, + required Widget dialog, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: ShadApp( + home: Scaffold( + body: Builder( + builder: (context) => ShadButton( + onPressed: () => showShadDialog( + context: context, + builder: (_) => dialog, + ), + child: const Text('Open dialog'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open dialog')); + await tester.pumpAndSettle(); +} + +class _ControlledFolderProvider extends FolderProvider { + final Queue> editResults = Queue(); + final Queue> deleteResults = Queue(); + int editCalls = 0; + int deleteCalls = 0; + + @override + String? build() => null; + + @override + Future editFolder({ + required Folder folder, + required String newName, + required int newIconId, + required FolderColor newColor, + required Color? newCustomColor, + LibraryWorkspace? workspace, + }) { + editCalls += 1; + return editResults.removeFirst(); + } + + @override + Future deleteFolder( + String folderID, { + LibraryWorkspace? workspace, + }) { + deleteCalls += 1; + return deleteResults.removeFirst(); + } +} + +class _ControlledStrategyProvider extends StrategyProvider { + final Queue> deleteResults = Queue(); + int deleteCalls = 0; + + @override + StrategyState build() => const StrategyState( + strategyId: null, + strategyName: null, + source: null, + storageDirectory: null, + isOpen: false, + ); + + @override + Future deleteStrategy( + String strategyID, { + StrategySource? source, + }) { + deleteCalls += 1; + return deleteResults.removeFirst(); + } +} + +class _CloudExportInvoker extends ConsumerWidget { + const _CloudExportInvoker(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ShadButton( + onPressed: () => runCloudStrategyExport(ref, 'strategy-1'), + child: const Text('Export cloud strategy'), + ); + } +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +Folder _folder() => Folder( + id: 'folder-1', + name: 'Defaults', + iconId: 0, + dateCreated: DateTime.utc(2026), + color: FolderColor.generic, + ); diff --git a/test/widgets/cloud_outbox_summary_banner_test.dart b/test/widgets/cloud_outbox_summary_banner_test.dart new file mode 100644 index 00000000..a79b3141 --- /dev/null +++ b/test/widgets/cloud_outbox_summary_banner_test.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/widgets/cloud_outbox_summary_banner.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + testWidgets('closed-strategy attention is visible with no editor open', + (tester) async { + String? openedStrategy; + final container = _container( + queue: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: null, + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'strategy-needs-review': StrategyOutboxSummary( + strategyPublicId: 'strategy-needs-review', + queuedCount: 0, + inFlightCount: 0, + pausedCount: 1, + attentionCount: 0, + successorCount: 0, + reason: 'ClientException: socket failed, bearer=secret', + ), + }, + ), + ), + ); + addTearDown(container.dispose); + + await _pump( + tester, + container, + CloudOutboxSummaryBanner( + onOpenStrategy: (id) => openedStrategy = id, + ), + ); + + expect(find.byKey(const ValueKey('cloud-outbox-summary')), findsOneWidget); + expect(find.text('Cloud work needs attention'), findsOneWidget); + expect(find.textContaining('Haven retake'), findsOneWidget); + expect(find.text('Haven retake: review sync'), findsOneWidget); + expect(find.textContaining('ClientException'), findsNothing); + expect(find.textContaining('bearer=secret'), findsNothing); + await tester.tap(find.textContaining('Haven retake')); + expect(openedStrategy, 'strategy-needs-review'); + }); + + testWidgets('queued closed-strategy work is visible while offline', + (tester) async { + final container = _container( + connected: false, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed': StrategyOutboxSummary( + strategyPublicId: 'closed', + queuedCount: 2, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + await tester.pump(); + + expect(find.text('Working offline'), findsOneWidget); + expect(find.textContaining('2 saved changes'), findsOneWidget); + expect(find.textContaining('waiting on this device'), findsOneWidget); + }); + + testWidgets('pending shared-strategy work is visible in Shared With Me', + (tester) async { + final container = _container( + section: CloudLibrarySection.sharedWithMe, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'shared-strategy': StrategyOutboxSummary( + strategyPublicId: 'shared-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect(find.byKey(const ValueKey('cloud-outbox-summary')), findsOneWidget); + expect(find.text('Syncing cloud work'), findsOneWidget); + expect(find.textContaining('1 saved change'), findsOneWidget); + }); + + testWidgets('another account work is absent from this account library', + (tester) async { + final container = _container( + queue: const StrategyOpQueueState( + accountId: 'account-b', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary(accountId: 'account-b'), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect( + find.byKey(const ValueKey('cloud-outbox-summary')), + findsNothing, + ); + }); + + testWidgets('outbox banner stays absent from the local workspace', + (tester) async { + final container = _container( + workspace: LibraryWorkspace.local, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed': StrategyOutboxSummary( + strategyPublicId: 'closed', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect( + find.byKey(const ValueKey('cloud-outbox-summary')), + findsNothing, + ); + }); + + testWidgets('auth-paused work shows the reason instead of syncing', + (tester) async { + final container = _container( + authReady: false, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed': StrategyOutboxSummary( + strategyPublicId: 'closed', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect(find.text('Cloud work needs attention'), findsOneWidget); + expect(find.textContaining('Reconnect this account'), findsOneWidget); + expect(find.text('Syncing cloud work'), findsNothing); + }); +} + +ProviderContainer _container({ + required StrategyOpQueueState queue, + LibraryWorkspace workspace = LibraryWorkspace.cloud, + CloudLibrarySection section = CloudLibrarySection.home, + bool connected = true, + bool authReady = true, +}) { + return ProviderContainer(overrides: [ + authProvider.overrideWith(() => _ReadyAuth(authReady)), + libraryWorkspaceProvider.overrideWith(() => _Workspace(workspace)), + cloudLibrarySectionProvider.overrideWith(() => _CloudSection(section)), + strategyOpQueueProvider.overrideWith(() => _Queue(queue)), + cloudMediaUploadQueueProvider.overrideWith(_MediaQueue.new), + convexConnectionProvider.overrideWith((ref) => Stream.value(connected)), + cloudStrategyNamesProvider.overrideWithValue(const { + 'strategy-needs-review': 'Haven retake', + }), + ]); +} + +Future _pump( + WidgetTester tester, + ProviderContainer container, + Widget banner, +) { + return tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ShadApp(home: Scaffold(body: banner)), + ), + ); +} + +class _Workspace extends LibraryWorkspaceNotifier { + _Workspace(this.workspace); + + final LibraryWorkspace workspace; + + @override + LibraryWorkspace build() => workspace; +} + +class _CloudSection extends CloudLibrarySectionNotifier { + _CloudSection(this.section); + + final CloudLibrarySection section; + + @override + CloudLibrarySection build() => section; +} + +class _ReadyAuth extends AuthProvider { + _ReadyAuth(this.ready); + + final bool ready; + + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: ready, + convexAuthStatus: + ready ? ConvexAuthStatus.ready : ConvexAuthStatus.incident, + user: const User( + id: 'account-a', + appMetadata: {}, + userMetadata: {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _Queue extends StrategyOpQueueNotifier { + _Queue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; +} + +class _MediaQueue extends CloudMediaUploadQueueNotifier { + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); +} diff --git a/test/widgets/cloud_sync_status_chip_test.dart b/test/widgets/cloud_sync_status_chip_test.dart index fa2e6b23..4dd40202 100644 --- a/test/widgets/cloud_sync_status_chip_test.dart +++ b/test/widgets/cloud_sync_status_chip_test.dart @@ -1,12 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/cloud_sync_status_provider.dart'; import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/cloud_sync_status_chip.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -32,6 +37,19 @@ class _SettledOpQueue extends StrategyOpQueueNotifier { ); } +class _UnreliableOpQueue extends StrategyOpQueueNotifier { + @override + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + hasDurabilityFailure: true, + lastError: 'Cloud work could not be verified in the durable outbox. ' + 'Nothing was sent.', + ); +} + class _EmptyMediaQueue extends CloudMediaUploadQueueNotifier { @override CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( @@ -40,18 +58,224 @@ class _EmptyMediaQueue extends CloudMediaUploadQueueNotifier { ); } -ProviderContainer _createContainer({bool connected = true}) { +class _FixedMediaQueue extends CloudMediaUploadQueueNotifier { + _FixedMediaQueue(this.initialState); + + final CloudMediaUploadQueueState initialState; + + @override + CloudMediaUploadQueueState build() => initialState; +} + +class _FixedOpQueue extends StrategyOpQueueNotifier { + _FixedOpQueue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; +} + +class _FixedSaveState extends StrategySaveStateNotifier { + _FixedSaveState(this.initialState); + + final StrategySaveState initialState; + + @override + StrategySaveState build() => initialState; +} + +class _AttentionOpQueue extends StrategyOpQueueNotifier { + _AttentionOpQueue( + this.rejectedCount, { + this.hasOtherStrategyAttention = false, + }); + + final int rejectedCount; + final bool hasOtherStrategyAttention; + int retryRejectedCount = 0; + int flushNowCount = 0; + + @override + StrategyOpQueueState build() => StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + attentionByEntityKey: { + for (var index = 0; index < rejectedCount; index++) + EntitySyncKey.element('page-1', 'element-$index'): + QueuedEntityIntent( + entityKey: EntitySyncKey.element('page-1', 'element-$index'), + pending: PendingOp( + op: ElementPatchOp( + opId: 'rejected-$index', + elementPublicId: 'element-$index', + pagePublicId: 'page-1', + payload: const {'value': 'mine'}, + expectedElementRevision: 1, + ), + clientId: 'client-a', + ), + ), + }, + accountOutbox: hasOtherStrategyAttention + ? const AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 0, + inFlightCount: 0, + pausedCount: 1, + attentionCount: 0, + successorCount: 0, + ), + }, + ) + : const AccountStrategyOutboxSummary(), + lastError: 'Some saved work needs attention.', + ); + + @override + Future retryPaused({bool flushImmediately = true}) async {} + + @override + Future retryRejected({bool flushImmediately = true}) async { + retryRejectedCount += 1; + } + + @override + Future flushNow() async { + flushNowCount += 1; + } +} + +class _ConflictSession extends StrategyPageSessionNotifier { + _ConflictSession({this.result = true, this.failure}); + + final bool result; + final Object? failure; + int useCloudCount = 0; + + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-1'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + + @override + Future useCloudVersionsForRejected() async { + useCloudCount += 1; + if (failure != null) throw failure!; + return result; + } +} + +ProviderContainer _createContainer({ + bool connected = true, + StrategyOpQueueState? opQueueState, + CloudMediaUploadQueueState? mediaQueueState, + StrategySaveState? saveState, +}) { return ProviderContainer( overrides: [ strategyProvider.overrideWith(_CloudStrategyProvider.new), - strategyOpQueueProvider.overrideWith(_SettledOpQueue.new), - cloudMediaUploadQueueProvider.overrideWith(_EmptyMediaQueue.new), + strategyOpQueueProvider.overrideWith( + opQueueState == null + ? _SettledOpQueue.new + : () => _FixedOpQueue(opQueueState), + ), + cloudMediaUploadQueueProvider.overrideWith( + mediaQueueState == null + ? _EmptyMediaQueue.new + : () => _FixedMediaQueue(mediaQueueState), + ), convexConnectionProvider.overrideWith((ref) => Stream.value(connected)), + if (saveState != null) + strategySaveStateProvider.overrideWith( + () => _FixedSaveState(saveState), + ), + ], + ); +} + +ProviderContainer _createConflictContainer({ + required _AttentionOpQueue queue, + required _ConflictSession session, +}) { + return ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_CloudStrategyProvider.new), + strategyOpQueueProvider.overrideWith(() => queue), + strategyPageSessionProvider.overrideWith(() => session), + cloudMediaUploadQueueProvider.overrideWith(_EmptyMediaQueue.new), + cloudMediaAccountIdProvider.overrideWithValue('account-a'), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), ], ); } void main() { + test('restored active-strategy media renders as syncing', () { + final container = _createContainer( + mediaQueueState: CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'restored-image', + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + assetPublicId: 'restored-image', + fileExtension: 'png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.utc(2026), + ), + ], + isProcessing: false, + ), + ); + addTearDown(container.dispose); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.syncing); + expect( + container.read(cloudSyncStatusProvider), + isNot(CloudSyncStatus.synced), + ); + }); + + test('restored active-strategy media errors remain visible offline', + () async { + final container = _createContainer( + connected: false, + mediaQueueState: CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'missing-image', + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + assetPublicId: 'missing-image', + fileExtension: 'png', + mimeType: 'image/png', + state: CloudMediaJobState.failed, + attempts: 1, + lastError: 'Local media file is missing.', + updatedAt: DateTime.utc(2026), + ), + ], + isProcessing: false, + ), + ); + addTearDown(container.dispose); + await container.read(convexConnectionProvider.future); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.attention); + }); + testWidgets('an active text draft can never appear synced', (tester) async { final container = _createContainer(); addTearDown(container.dispose); @@ -111,4 +335,424 @@ void main() { expect(find.text('Editing…'), findsNothing); expect(find.text('Synced'), findsNothing); }); + + test('status provider prioritizes connectivity over an offline flush error', + () async { + final container = _createContainer( + connected: false, + saveState: const StrategySaveState( + isDirty: true, + isSaving: false, + hasPendingCloudSync: true, + cloudSyncError: 'Cloud connection is offline.', + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, + lastPersistedAt: null, + ), + ); + addTearDown(container.dispose); + await container.read(convexConnectionProvider.future); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.offline); + }); + + test('real queue attention remains visible while offline', () async { + const entityKey = EntitySyncKey.strategy(); + const intent = QueuedEntityIntent( + entityKey: entityKey, + pending: PendingOp( + op: StrategyPatchOp( + opId: 'conflicted-op', + payload: {'name': 'conflicted'}, + expectedStrategyRevision: 1, + ), + clientId: 'client-a', + ), + ); + final container = _createContainer( + connected: false, + opQueueState: StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + attentionByEntityKey: { + entityKey: intent, + }, + durableLoaded: true, + ), + ); + addTearDown(container.dispose); + await container.read(convexConnectionProvider.future); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.attention); + }); + + test('queued work in another strategy prevents a synced status', () { + final container = _createContainer( + opQueueState: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.syncing); + }); + + testWidgets('inactive attention directs the user to the cloud library', + (tester) async { + final container = _createContainer( + opQueueState: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 0, + inFlightCount: 0, + pausedCount: 1, + attentionCount: 0, + successorCount: 0, + reason: 'Retry limit reached', + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + + expect(find.text('Needs attention'), findsOneWidget); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + expect( + find.text( + 'Saved work in another strategy needs attention. Open it from the ' + 'Cloud library to review the reason.', + ), + findsOneWidget, + ); + expect(find.text('Retry sync'), findsNothing); + }); + + test('media errors remain visible while offline', () async { + final container = _createContainer( + connected: false, + saveState: const StrategySaveState( + isDirty: false, + isSaving: false, + hasPendingCloudSync: true, + cloudSyncError: null, + hasPendingMediaSync: true, + mediaSyncErrorCount: 1, + lastPersistedAt: null, + ), + ); + addTearDown(container.dispose); + await container.read(convexConnectionProvider.future); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.attention); + }); + + testWidgets('offline flush errors render Offline instead of Needs attention', + (tester) async { + final container = _createContainer( + connected: false, + saveState: const StrategySaveState( + isDirty: true, + isSaving: false, + hasPendingCloudSync: true, + cloudSyncError: 'Cloud connection is offline.', + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, + lastPersistedAt: null, + ), + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('Offline'), findsOneWidget); + expect(find.text('Needs attention'), findsNothing); + }); + + testWidgets('durability uncertainty never appears synced or reliable', + (tester) async { + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_CloudStrategyProvider.new), + strategyOpQueueProvider.overrideWith(_UnreliableOpQueue.new), + cloudMediaUploadQueueProvider.overrideWith(_EmptyMediaQueue.new), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + + final queue = container.read(strategyOpQueueProvider); + expect(queue.outboxIsReliable, isFalse); + expect(find.text('Needs attention'), findsOneWidget); + expect(find.text('Synced'), findsNothing); + + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + expect(find.text('Retry sync'), findsOneWidget); + expect(find.textContaining('safely stored'), findsNothing); + }); + + testWidgets('conflict popover offers an explicit cloud choice', + (tester) async { + final queue = _AttentionOpQueue(2); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + expect( + find.textContaining('applies to all 2 conflicting changes'), + findsOneWidget, + ); + + await tester.tap(find.text('Use cloud')); + await tester.pumpAndSettle(); + + expect(session.useCloudCount, 1); + expect(queue.retryRejectedCount, 0); + expect(queue.flushNowCount, 0); + }); + + testWidgets( + 'active conflict controls remain when another strategy needs attention', + (tester) async { + final queue = _AttentionOpQueue( + 1, + hasOtherStrategyAttention: true, + ); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + expect(find.textContaining('Choose which version to keep'), findsOneWidget); + expect( + find.textContaining('another strategy also needs attention'), + findsOneWidget, + ); + expect(find.textContaining('Cloud library'), findsOneWidget); + }); + + testWidgets('keep mine remains an explicit rejected retry', (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + + await tester.tap(find.text('Keep mine')); + await tester.pumpAndSettle(); + + expect(queue.retryRejectedCount, 1); + expect(queue.flushNowCount, 1); + expect(session.useCloudCount, 0); + }); + + testWidgets('oversized saved work shows its durable attention reason', + (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + container + .read(strategySaveStateProvider.notifier) + .setCloudSyncError(cloudOperationTooLargeMessage); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('too large for cloud sync'), + findsOneWidget, + ); + expect(find.textContaining('Another edit reached'), findsNothing); + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + }); + + testWidgets('failed cloud load keeps attention and explains the failure', + (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(result: false); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use cloud')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Could not load the cloud version. Your saved version was not changed.', + ), + findsOneWidget, + ); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + hasLength(1), + ); + }); + + testWidgets('thrown cloud load keeps attention and explains the failure', + (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(failure: StateError('refresh failed')); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: CloudSyncStatusChip()), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Needs attention')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use cloud')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Could not load the cloud version. Your saved version was not changed.', + ), + findsOneWidget, + ); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + hasLength(1), + ); + }); } diff --git a/test/widgets/settings_sign_out_test.dart b/test/widgets/settings_sign_out_test.dart new file mode 100644 index 00000000..9d18ddad --- /dev/null +++ b/test/widgets/settings_sign_out_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/widgets/settings_tab.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + testWidgets('Settings account action uses guarded sign out', (tester) async { + var requests = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(_SignedInAuth.new), + guardedSignOutRequestProvider.overrideWithValue((context) async { + requests += 1; + return true; + }), + ], + child: const ShadApp( + home: Scaffold(body: AccountSettingsSection()), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('settings-sign-out'))); + await tester.pump(); + expect(requests, 1); + }); +} + +class _SignedInAuth extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {'full_name': 'Coach'}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} diff --git a/tool/audit_convex_contract.mjs b/tool/audit_convex_contract.mjs index 4e0362b5..f4f16a49 100644 --- a/tool/audit_convex_contract.mjs +++ b/tool/audit_convex_contract.mjs @@ -125,6 +125,18 @@ if ( fail(`${functionSpecEntry.identifier} is missing an args validator`); } else { auditValidator(functionSpecEntry.args, ["args"], functionSpecEntry.identifier); + if (functionSpecEntry.functionType === "Mutation") { + const protocolField = functionSpecEntry.args.value?.clientProtocolVersion; + if ( + functionSpecEntry.args.type !== "object" || + protocolField?.optional !== false || + protocolField?.fieldType?.type !== "number" + ) { + fail( + `${functionSpecEntry.identifier} must require numeric clientProtocolVersion`, + ); + } + } } if (functionSpecEntry.returns === null) { fail(`${functionSpecEntry.identifier} is missing a return validator`); diff --git a/tool/snapshot_convex_contract.mjs b/tool/snapshot_convex_contract.mjs index 4cfcdd11..f33228fd 100644 --- a/tool/snapshot_convex_contract.mjs +++ b/tool/snapshot_convex_contract.mjs @@ -25,8 +25,11 @@ function sortObjectKeys(value) { } function readFunctionSpec() { - const executable = process.platform === "win32" ? "npx.cmd" : "npx"; - const args = ["convex", "function-spec"]; + const executable = process.execPath; + const args = [ + resolve(repositoryRoot, "node_modules", "convex", "bin", "main.js"), + "function-spec", + ]; const previewName = process.env.CONVEX_PREVIEW_NAME; if (previewName) { args.push("--preview-name", previewName); @@ -72,7 +75,7 @@ function writeOrCheck(path, contents) { return; } const committed = readFileSync(path, "utf8"); - if (committed !== contents) { + if (committed.replaceAll("\r\n", "\n") !== contents) { throw new Error(`${path} is stale; run npm run snapshot:convex-contract`); } }