From 969a2cd9c4b9823e728814e7d7bc915b02f1be1b Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 10 Oct 2026 13:04:11 -0400 Subject: [PATCH 1/2] Keep items' roots when a cloud strategy is duplicated A duplicate gave every item and lineup a fresh random id, so items the source had copied between pages were no longer copies of each other in the duplicate: its page transitions faded them instead of gliding. Each now gets a copy id that keeps its root (lib/pageCopyId.ts, mirroring the app's page_copy_id.dart), unless the root is too long for the app to store a change under. Co-Authored-By: Claude Opus 5.5 --- convex/lib/pageCopyId.ts | 37 ++++++++++++++++++++++ convex/pageCopyId.test.ts | 39 +++++++++++++++++++++++ convex/strategies.ts | 13 ++++++-- convex/strategyDuplicate.test.ts | 53 ++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 convex/lib/pageCopyId.ts create mode 100644 convex/pageCopyId.test.ts diff --git a/convex/lib/pageCopyId.ts b/convex/lib/pageCopyId.ts new file mode 100644 index 00000000..fc720eb0 --- /dev/null +++ b/convex/lib/pageCopyId.ts @@ -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): `~cp1~`, +/// 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`: `~cp1~`. +export function pageCopyId(id: string, freshId: () => string): string { + const root = pageCopyRoot(id); + const fresh = freshId(); + return encodeURIComponent(root).length > maxKeptRootLength + ? fresh + : `${root}${copyMark}${fresh}`; +} diff --git a/convex/pageCopyId.test.ts b/convex/pageCopyId.test.ts new file mode 100644 index 00000000..876a0974 --- /dev/null +++ b/convex/pageCopyId.test.ts @@ -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); + }); +}); diff --git a/convex/strategies.ts b/convex/strategies.ts index 11c7e7f5..0ff6f963 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -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, @@ -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(); 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; }; } @@ -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) diff --git a/convex/strategyDuplicate.test.ts b/convex/strategyDuplicate.test.ts index 7f868d18..c852be0c 100644 --- a/convex/strategyDuplicate.test.ts +++ b/convex/strategyDuplicate.test.ts @@ -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", @@ -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; + 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); From 3a96d054160f4b3ec1699af56e0303dc534dcfd8 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 10 Oct 2026 13:24:19 -0400 Subject: [PATCH 2/2] Regenerate Convex bindings for lib/pageCopyId Co-Authored-By: Claude Opus 5.5 --- convex/_generated/api.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 369f89f8..a13dc784 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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"; @@ -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;