-
Notifications
You must be signed in to change notification settings - Fork 20
Ask the server which images it still shows before dropping an upload #233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0854ebc
6a30586
d5f9209
79e444e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| import { | ||
| convexTest, | ||
| type TestConvexForDataModel, | ||
| type TestConvexForDataModelAndIdentity, | ||
| } from "convex-test"; | ||
| import { makeFunctionReference } from "convex/server"; | ||
| import { describe, expect, test } from "vitest"; | ||
| import type { DataModel } from "./_generated/dataModel"; | ||
| import { markAssetReferencesReady } from "./lib/assetReferences"; | ||
| 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 applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); | ||
| const createShare = makeFunctionReference<"mutation">("shares:create"); | ||
| const redeemShare = makeFunctionReference<"mutation">("shares:redeem"); | ||
| const getFullSnapshot = makeFunctionReference<"query">( | ||
| "strategy:getFullSnapshot", | ||
| ); | ||
| const listReferencedAssetIds = makeFunctionReference<"query">( | ||
| "images:listReferencedAssetIds", | ||
| ); | ||
|
|
||
| type Harness = TestConvexForDataModel<DataModel>; | ||
| type RootHarness = TestConvexForDataModelAndIdentity<DataModel>; | ||
|
|
||
| const protocol = { clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION }; | ||
| const strategyPublicId = "referenced-assets-strategy"; | ||
| const pagePublicId = "referenced-assets-page"; | ||
| // Every image the tests' content ever showed, and one it never did. | ||
| const asked = { | ||
| strategyPublicId, | ||
| assetPublicIds: ["removed", "shown", "shot", "never-placed"], | ||
| }; | ||
|
|
||
| function identity(subject: string) { | ||
| return { | ||
| issuer: "https://referenced-assets.test", | ||
| subject, | ||
| tokenIdentifier: `referenced-assets|${subject}`, | ||
| name: subject, | ||
| }; | ||
| } | ||
|
|
||
| function imagePayload(assetPublicId: string) { | ||
| return { | ||
| kind: "image" as const, | ||
| payloadVersion: 1, | ||
| data: { id: assetPublicId, elementType: "image" }, | ||
| }; | ||
| } | ||
|
|
||
| async function user(t: RootHarness, subject: string): Promise<Harness> { | ||
| const harness = t.withIdentity(identity(subject)); | ||
| await harness.mutation(ensureCurrentUser, protocol); | ||
| return harness; | ||
| } | ||
|
|
||
| let opCounter = 0; | ||
|
|
||
| async function apply(owner: Harness, ops: Array<Record<string, unknown>>) { | ||
| const result = (await owner.mutation(applyBatch, { | ||
| ...protocol, | ||
| strategyPublicId, | ||
| clientId: "client", | ||
| ops: ops.map((op) => ({ opId: `op-${++opCounter}`, ...op })), | ||
| })) as { results: Array<{ status: string }> }; | ||
| expect(result.results.map((entry) => entry.status)).toEqual( | ||
| ops.map(() => "applied"), | ||
| ); | ||
| } | ||
|
|
||
| /// A strategy whose page shows 'shown' as an image and 'shot' in a lineup, | ||
| /// and once showed 'removed'. | ||
| async function seed(t: RootHarness): Promise<Harness> { | ||
| const owner = await user(t, "owner"); | ||
| await owner.mutation(createStrategy, { | ||
| ...protocol, | ||
| publicId: strategyPublicId, | ||
| name: "Referenced assets", | ||
| mapData: "ascent", | ||
| initialPagePublicId: pagePublicId, | ||
| initialPageName: "Page 1", | ||
| initialPageIsAttack: true, | ||
| }); | ||
| await apply(owner, [ | ||
| { | ||
| type: "element.add", | ||
| elementPublicId: "shown", | ||
| pagePublicId, | ||
| payload: imagePayload("shown"), | ||
| sortIndex: 0, | ||
| }, | ||
| { | ||
| type: "element.add", | ||
| elementPublicId: "removed", | ||
| pagePublicId, | ||
| payload: imagePayload("removed"), | ||
| sortIndex: 1, | ||
| }, | ||
| { | ||
| type: "lineup.add", | ||
| lineupPublicId: "lineupLink:k", | ||
| pagePublicId, | ||
| payload: { | ||
| kind: "lineupLink" as const, | ||
| payloadVersion: 1, | ||
| data: { | ||
| id: "k", | ||
| originId: "o", | ||
| landingId: "l", | ||
| images: [{ id: "shot" }], | ||
| }, | ||
| }, | ||
| sortIndex: 0, | ||
| }, | ||
| ]); | ||
| await apply(owner, [ | ||
| { | ||
| type: "element.delete", | ||
| elementPublicId: "removed", | ||
| pagePublicId, | ||
| expectedElementRevision: 1, | ||
| }, | ||
| ]); | ||
| return owner; | ||
| } | ||
|
|
||
| describe("images:listReferencedAssetIds", () => { | ||
| test("answers which of the images asked about the strategy's content shows, deleted content left out", async () => { | ||
| const t = convexTest(schema, modules); | ||
| await t.run(markAssetReferencesReady); | ||
| const owner = await seed(t); | ||
|
|
||
| expect( | ||
| await owner.query(listReferencedAssetIds, asked), | ||
| ).toEqual(["shot", "shown"]); | ||
| }); | ||
|
|
||
| test("reads a bounded number of rows: it answers at most 100 images at once", async () => { | ||
| const t = convexTest(schema, modules); | ||
| await t.run(markAssetReferencesReady); | ||
| const owner = await seed(t); | ||
| const many = Array.from({ length: 100 }, (_, index) => `image-${index}`); | ||
|
|
||
| expect( | ||
| await owner.query(listReferencedAssetIds, { | ||
| strategyPublicId, | ||
| assetPublicIds: [...many.slice(1), "shown"], | ||
| }), | ||
| ).toEqual(["shown"]); | ||
| await expect( | ||
| owner.query(listReferencedAssetIds, { | ||
| strategyPublicId, | ||
| assetPublicIds: [...many, "shown"], | ||
| }), | ||
| ).rejects.toThrow(/at most 100/); | ||
| }); | ||
|
|
||
| test("is null until the reference backfill has finished", async () => { | ||
| const t = convexTest(schema, modules); | ||
| const owner = await seed(t); | ||
|
|
||
| expect( | ||
| await owner.query(listReferencedAssetIds, asked), | ||
| ).toBeNull(); | ||
| }); | ||
|
|
||
| test("answers anyone who can view the strategy, and no one else", async () => { | ||
| const t = convexTest(schema, modules); | ||
| await t.run(markAssetReferencesReady); | ||
| const owner = await seed(t); | ||
| const viewer = await user(t, "viewer"); | ||
| await owner.mutation(createShare, { | ||
| ...protocol, | ||
| targetType: "strategy", | ||
| targetPublicId: strategyPublicId, | ||
| token: "viewer-token", | ||
| role: "viewer", | ||
| }); | ||
| await viewer.mutation(redeemShare, { ...protocol, token: "viewer-token" }); | ||
| const stranger = await user(t, "stranger"); | ||
|
|
||
| expect( | ||
| await viewer.query(listReferencedAssetIds, asked), | ||
| ).toEqual(["shot", "shown"]); | ||
| await expect( | ||
| stranger.query(listReferencedAssetIds, asked), | ||
| ).rejects.toThrow(); | ||
| }); | ||
|
|
||
| test("a client that checks references apart says so when it reads a full snapshot", async () => { | ||
| const t = convexTest(schema, modules); | ||
| await t.run(markAssetReferencesReady); | ||
| const owner = await seed(t); | ||
|
|
||
| const snapshot = (await owner.query(getFullSnapshot, { | ||
| strategyPublicId, | ||
| acceptsTrashedPagesLeftOut: true, | ||
| })) as { pages: Array<{ publicId: string }> }; | ||
| expect(snapshot.pages.map((page) => page.publicId)).toEqual([ | ||
| pagePublicId, | ||
| ]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import 'dart:math' show min; | ||
|
|
||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||
| import 'package:icarus/collab/cloud_media_models.dart'; | ||
| import 'package:icarus/collab/cloud_library_models.dart'; | ||
|
|
@@ -157,6 +159,33 @@ class ConvexStrategyRepository { | |
| .map(_pageSnapshot); | ||
| } | ||
|
|
||
| /// Which of [assetPublicIds] the strategy's content shows on the server, | ||
| /// or null while the server cannot tell yet. Asked a batch at a time. | ||
| Future<Set<String>?> fetchReferencedAssetIds( | ||
| String strategyPublicId, | ||
| Iterable<String> assetPublicIds, | ||
| ) async { | ||
| final asked = assetPublicIds.toSet().toList(growable: false); | ||
| final referenced = <String>{}; | ||
| for (var start = 0; start < asked.length; start += _referencedIdsBatch) { | ||
| final ids = await _api.images | ||
| .listReferencedAssetIds( | ||
| strategyPublicId: strategyPublicId, | ||
| assetPublicIds: asked.sublist( | ||
| start, | ||
| min(start + _referencedIdsBatch, asked.length), | ||
| ), | ||
| ) | ||
| .fetch(); | ||
| if (ids == null) return null; | ||
| referenced.addAll(ids); | ||
| } | ||
| return referenced; | ||
| } | ||
|
|
||
| /// The server's MAX_REFERENCED_ASSET_IDS_PER_QUERY. | ||
| static const _referencedIdsBatch = 100; | ||
|
|
||
| /// [shareToken]: see [fetchShell]. | ||
| Future<RemoteFullStrategySnapshot> fetchFullSnapshot( | ||
| String strategyPublicId, { | ||
|
|
@@ -167,6 +196,9 @@ class ConvexStrategyRepository { | |
| .getFullSnapshot( | ||
| strategyPublicId: strategyPublicId, | ||
| shareToken: _optional(shareToken), | ||
| // This client checks image references apart, so it can take a | ||
| // snapshot without the pages in the server's trash. | ||
| acceptsTrashedPagesLeftOut: const ConvexOptional.present(true), | ||
| ) | ||
|
Comment on lines
+199
to
202
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If this client connects to a deployment that has not yet accepted ArtifactsLocal version-skew validation command
Previous and current server contract test
Local contract test configuration
Previous server rejects the new argument
Current server accepts the new argument
Dart repository propagates rejection after one attempt
Complete local version-skew execution
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Production can't hit this: Deploy Web deploys Convex before it publishes the web build, and a cancelled run can leave a new server with the old web, never the reverse. This PR's server accepts the argument before any client sends it. The only backend that lags is the dev deployment (
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That rollout constraint addresses the finding. Deploy Web publishes the Convex contract before the client bundle, so production cannot receive this client while still serving a server that rejects The lagging |
||
| .fetch(), | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR #228 still calls
images:listReferencedAssetIdswith onlystrategyPublicId, including from its repository method and page-trash tests. RequiringassetPublicIdsrejects those calls before the query runs, so the stacked client cannot check image references. Update PR #228’s callers and generated contract before merging the stack.Artifacts
Higher-PR caller and candidate validator source
Query test against higher PR before the change
Same query test against candidate PR after the change
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right that the stack must move together, and it already has. #228 (branch
page-trash) merged this change in acdcb33: its repository method passesassetPublicIdsin batches of 100, its tests pass them, andlib/collab/generatedwas regenerated from the updatedfunction_spec.json. The one-argument calls you saw are at f3eec36, which predates that merge. No shipped client ever called the one-argument form: the query is new in this PR, and the live web build doesn't call it.