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
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type * as lib_fieldMerge from "../lib/fieldMerge.js";
import type * as lib_imageAssets from "../lib/imageAssets.js";
import type * as lib_lineupItems from "../lib/lineupItems.js";
import type * as lib_opTypes from "../lib/opTypes.js";
import type * as lib_pageCopyId from "../lib/pageCopyId.js";
import type * as lib_payloadValidators from "../lib/payloadValidators.js";
import type * as lib_profile from "../lib/profile.js";
import type * as lib_publicValidators from "../lib/publicValidators.js";
Expand Down Expand Up @@ -64,6 +65,7 @@ declare const fullApi: ApiFromModules<{
"lib/imageAssets": typeof lib_imageAssets;
"lib/lineupItems": typeof lib_lineupItems;
"lib/opTypes": typeof lib_opTypes;
"lib/pageCopyId": typeof lib_pageCopyId;
"lib/payloadValidators": typeof lib_payloadValidators;
"lib/profile": typeof lib_profile;
"lib/publicValidators": typeof lib_publicValidators;
Expand Down
37 changes: 37 additions & 0 deletions convex/lib/pageCopyId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/// Ids of items copied to another page, as the app writes them (see
/// lib/const/page_copy_id.dart, which this mirrors): `<root>~cp1~<uuid>`,
/// where the root is the id of the item first copied. A copy of a copy keeps
/// that root, so ids never nest, and the app's page transition pairs items
/// across pages by root, so a copied item glides from one page to the next.

const copyMark = "~cp1~";

const uuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;

/// The id `id` was first copied from, or `id` itself when it is no copy.
/// Only an id ending in the mark and a canonical uuid is a copy, so an id
/// that merely contains `~` (imports take any string) stays its own root.
export function pageCopyRoot(id: string): string {
const at = id.lastIndexOf(copyMark);
if (at <= 0) return id;
return uuid.test(id.substring(at + copyMark.length))
? id.substring(0, at)
: id;
}

/// The longest root a copy keeps, URL-encoded. The app stores each queued
/// change under a key of about 120 characters plus the item's encoded id
/// (DurableOutboxRecord.createStorageKey), and its storage refuses keys over
/// 255, so a copy of an item imported with an unusually long id gets a plain
/// id instead: it then fades between pages rather than gliding.
const maxKeptRootLength = 80;

/// A new id for a copy of `id`, made with `freshId`: `<root>~cp1~<fresh>`.
export function pageCopyId(id: string, freshId: () => string): string {
const root = pageCopyRoot(id);
const fresh = freshId();
return encodeURIComponent(root).length > maxKeptRootLength
? fresh
: `${root}${copyMark}${fresh}`;
}
39 changes: 39 additions & 0 deletions convex/pageCopyId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { pageCopyId, pageCopyRoot } from "./lib/pageCopyId";

const fresh = "6f1c2d0e-3b4a-4c5d-8e9f-0a1b2c3d4e5f";

describe("page copy ids", () => {
test("a copy's root is the id it was first copied from", () => {
expect(pageCopyRoot("agent-1")).toBe("agent-1");
expect(pageCopyRoot(`agent-1~cp1~${fresh}`)).toBe("agent-1");
});

test("an id that only looks like a copy stays its own root", () => {
for (const id of [
"a~cp1~not-a-uuid",
`~cp1~${fresh}`,
`agent-1~cp1~${fresh.toUpperCase()}`,
"team~notes",
]) {
expect(pageCopyRoot(id)).toBe(id);
}
});

test("a copy of a copy keeps the first root, so ids never nest", () => {
const first = pageCopyId("agent-1", () => fresh);
expect(first).toBe(`agent-1~cp1~${fresh}`);
expect(pageCopyId(first, () => "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d")).toBe(
"agent-1~cp1~0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d",
);
});

test("an unusually long root gets a plain id the app can store", () => {
expect(pageCopyId("x".repeat(81), () => fresh)).toBe(fresh);
expect(pageCopyId("x".repeat(80), () => fresh)).toBe(
`${"x".repeat(80)}~cp1~${fresh}`,
);
// Measured as the app stores it, URL-encoded.
expect(pageCopyId(" ".repeat(30), () => fresh)).toBe(fresh);
});
});
13 changes: 10 additions & 3 deletions convex/strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "./lib/auth";
import type { StrategyRole } from "./lib/auth";
import { lineupGroupItems } from "./lib/lineupItems";
import { pageCopyId } from "./lib/pageCopyId";
import {
getFolderByPublicId,
getStrategyByPublicId,
Expand Down Expand Up @@ -540,12 +541,16 @@ type LineupPayload = Doc<"lineups">["payload"];
/// New ids for a copied strategy's lineups: one map shared by every row and
/// every id in it, so one source id always becomes one copy id. Ids the
/// source repeats within a group (a landing may take its lineup's id, a
/// link names its origin and landing) repeat in the copy the same way.
/// link names its origin and landing) repeat in the copy the same way. Each
/// keeps its root (see lib/pageCopyId.ts), so lineups copied between the
/// source's pages are still copies of each other in the copy.
function lineupIdMap() {
const ids = new Map<string, string>();
return (id: string): string => {
let next = ids.get(id);
if (next === undefined) ids.set(id, (next = createPublicId()));
if (next === undefined) {
ids.set(id, (next = pageCopyId(id, createPublicId)));
}
return next;
};
}
Expand Down Expand Up @@ -793,7 +798,9 @@ export const duplicate = mutation({
for (const element of sourceElements) {
const pageId = pageIdMap.get(element.pageId);
if (element.deleted || pageId === undefined) continue;
const publicId = createPublicId();
// The copy keeps the item's root, so items copied between the
// source's pages still pair up, and glide, in the copy.
const publicId = pageCopyId(element.publicId, createPublicId);
const sourceAssetId =
element.elementType === "image"
? collectAssetIdFromElementPayload(element.payload)
Expand Down
53 changes: 53 additions & 0 deletions convex/strategyDuplicate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol";
import schema from "./schema";
import { lineupsPayload, oneLineupPayload } from "./testContent.helpers";
import { modules } from "./test.setup";
import { pageCopyRoot } from "./lib/pageCopyId";

const ensureCurrentUser = makeFunctionReference<"mutation">(
"users:ensureCurrentUser",
Expand Down Expand Up @@ -283,6 +284,58 @@ afterEach(() => {
});

describe("strategies:duplicate", () => {
test("copies keep their roots, so items copied between pages still pair", async () => {
const { t, owner } = await createHarness();
await seedSource(t, owner);
// The agent on page 2 was copied back to page 1, as the app copies.
const agentCopy = "placed-agent~cp1~6f1c2d0e-3b4a-4c5d-8e9f-0a1b2c3d4e5f";
await owner.mutation(applyBatch, {
...protocol,
strategyPublicId: source,
clientId: "copy-agent",
ops: [
{
opId: "add-agent-copy",
type: "element.add",
elementPublicId: agentCopy,
pagePublicId: firstPage,
payload: {
kind: "agent",
payloadVersion: 1,
data: { id: agentCopy, type: "jett" },
},
sortIndex: 5,
},
],
});

await duplicate(owner);

type Row = Record<string, any>;
const copy = (await owner.query(getFullSnapshot, {
...protocol,
strategyPublicId: "duplicate-copy",
})) as { elements: Row[]; lineups: Row[] };
const agents = copy.elements.filter((row) => row.elementType === "agent");
expect(agents).toHaveLength(2);
// Both agents in the copy are copies of the same root, under new ids.
expect(agents.map((row) => pageCopyRoot(row.publicId))).toEqual([
"placed-agent",
"placed-agent",
]);
expect(new Set(agents.map((row) => row.publicId)).size).toBe(2);
for (const agent of agents) {
expect([agentCopy, "placed-agent"]).not.toContain(agent.publicId);
expect(agent.payload.data.id).toBe(agent.publicId);
}
const image = copy.elements.find((row) => row.elementType === "image")!;
expect(pageCopyRoot(image.publicId)).toBe("placed-image");
// Lineups keep their roots too.
const lineup = copy.lineups[0]!;
expect(pageCopyRoot(lineup.publicId)).toBe("item-1");
expect(pageCopyRoot(lineup.payload.data.origins[0].id)).toBe("origin-1");
});

test("copies pages, live content, and images under fresh ids", async () => {
const { t, owner } = await createHarness();
await seedSource(t, owner);
Expand Down
Loading