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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions convex/function_spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -40660,6 +40660,47 @@
"kind": "internal"
}
},
{
"args": {
"type": "object",
"value": {
"assetPublicIds": {
"fieldType": {
"type": "array",
"value": {
"type": "string"
}
},
"optional": false
},
"strategyPublicId": {
"fieldType": {
"type": "string"
},
"optional": false
}
}
},
"functionType": "Query",
"identifier": "images.js:listReferencedAssetIds",
"returns": {
"type": "union",
"value": [
{
"type": "array",
"value": {
"type": "string"
}
},
{
"type": "null"
}
]
},
"visibility": {
"kind": "public"
}
},
{
"args": {
"type": "object",
Expand Down Expand Up @@ -182488,6 +182529,12 @@
"args": {
"type": "object",
"value": {
"acceptsTrashedPagesLeftOut": {
"fieldType": {
"type": "boolean"
},
"optional": true
},
"shareToken": {
"fieldType": {
"type": "string"
Expand Down
39 changes: 39 additions & 0 deletions convex/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,45 @@ export const listForStrategy = query({
},
});

/// At most this many images are asked about at once, so the answer reads a
/// bounded number of rows however much content the strategy has.
export const MAX_REFERENCED_ASSET_IDS_PER_QUERY = 100;

/// Which of [assetPublicIds] the strategy's content shows, deleted content
/// left out. A client asks before it drops an upload it holds. Null until
/// the reference backfill has finished, when it cannot be told.
export const listReferencedAssetIds = query({
args: {
strategyPublicId: v.string(),
assetPublicIds: v.array(v.string()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stacked image calls fail

PR #228 still calls images:listReferencedAssetIds with only strategyPublicId, including from its repository method and page-trash tests. Requiring assetPublicIds rejects 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

  • Captured the executed git-show commands identifying the one-argument caller and the new required validator field, establishing the contract mismatch.

Query test against higher PR before the change

  • Ran the higher PR’s query test against its own revision; the one-argument call passed.

Same query test against candidate PR after the change

  • Ran the unchanged higher-PR test against the candidate revision; validation rejected the missing `assetPublicIds` field.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Owner Author

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 passes assetPublicIds in batches of 100, its tests pass them, and lib/collab/generated was regenerated from the updated function_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.

},
returns: v.union(v.array(v.string()), v.null()),
handler: async (ctx, args) => {
if (args.assetPublicIds.length > MAX_REFERENCED_ASSET_IDS_PER_QUERY) {
throw invalidPayloadError(
`Ask about at most ${MAX_REFERENCED_ASSET_IDS_PER_QUERY} images at once.`,
);
}
const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId);
await assertStrategyRole(ctx, strategy, "viewer");
if (!(await assetReferencesReady(ctx))) return null;
const referenced: string[] = [];
for (const assetPublicId of new Set(args.assetPublicIds)) {
const reference = await ctx.db
.query("assetReferences")
.withIndex("by_strategyId_and_assetPublicId_and_deleted", (q) =>
q
.eq("strategyId", strategy._id)
.eq("assetPublicId", assetPublicId)
.eq("deleted", false),
)
.first();
if (reference !== null) referenced.push(assetPublicId);
}
return referenced.sort();
},
});

export const getAssetUrl = query({
args: {
strategyPublicId: v.string(),
Expand Down
211 changes: 211 additions & 0 deletions convex/referencedAssetIds.test.ts
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,
]);
});
});
5 changes: 5 additions & 0 deletions convex/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export const getFullSnapshot = query({
args: {
strategyPublicId: v.string(),
shareToken: v.optional(v.string()),
// Set by clients that ask images:listReferencedAssetIds, not this
// snapshot, whether an upload is still wanted: a snapshot that leaves
// deleted pages out cannot make them drop one. Ignored until pages can
// be deleted into a trash.
acceptsTrashedPagesLeftOut: v.optional(v.boolean()),
},
returns: fullStrategySnapshotValidator,
handler: async (ctx, args) => {
Expand Down
32 changes: 32 additions & 0 deletions lib/collab/convex_strategy_repository.dart
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';
Expand Down Expand Up @@ -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, {
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Older servers reject snapshots

If this client connects to a deployment that has not yet accepted acceptsTrashedPagesLeftOut, fetchFullSnapshot sends an argument the server rejects and does not retry without it. Cloud strategy export, video export, and the all-pages settings action then cannot complete. Deploy the server contract first or handle the older contract.

Artifacts

Local version-skew validation command

  • The authored command reconstructs the previous query from Git, runs both local contract checks and the client check, then removes the temporary copy; it provides a reproducible safe run.

Previous and current server contract test

  • The authored test sends the same new-client arguments to the previous and current query validators; it isolates the version-skew difference.

Local contract test configuration

  • The authored configuration selects only the version-skew test in the local Convex test environment; no deployment is involved.

Previous server rejects the new argument

  • A local run against the previous query contract succeeded without the field and rejected the new-client request with an unexpected-field validator error; the legacy contract is incompatible.

Current server accepts the new argument

  • A local run sent the same request to the current query and received a one-page snapshot; the updated contract accepts it.

Dart repository retry test

  • The authored test exercises `fetchFullSnapshot` against a transport returning the observed validation failure; it checks whether the client retries.

Dart repository propagates rejection after one attempt

  • The executed Dart test records the sent arguments, propagated exception, and one query attempt; there is no fallback retry.

Complete local version-skew execution

  • The captured command, working directory, exit code, and output cover both server-contract runs and the Dart retry check; all checks reproduced the compatibility failure without deployment.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 (majestic-eel-413), which CI doesn't deploy. It would also be missing images:listReferencedAssetIds, so it has to be redeployed from icarus-cloud before a dev build uses it; both PR bodies say so. I haven't added a retry without the argument. The reason isn't that a flagless read would lose data: against a server that predates the trash it is harmless. It's that telling a validation error apart from other failures means matching error text, and the only case it covers is the dev deployment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 acceptsTrashedPagesLeftOut; a cancelled run can only leave the new server with the old client, which is compatible.

The lagging majestic-eel-413 deployment is an explicitly known dev-only case that also lacks images:listReferencedAssetIds and must be redeployed before using this build. Given that scope, adding a retry based on matching validation-error text would be brittle and is not necessary. I’m withdrawing this finding.

.fetch(),
);
Expand Down
Loading
Loading