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 @@ -21,6 +21,7 @@ import type * as lib_cloudProtocol from "../lib/cloudProtocol.js";
import type * as lib_entities from "../lib/entities.js";
import type * as lib_errors from "../lib/errors.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_payloadValidators from "../lib/payloadValidators.js";
import type * as lib_profile from "../lib/profile.js";
Expand Down Expand Up @@ -59,6 +60,7 @@ declare const fullApi: ApiFromModules<{
"lib/entities": typeof lib_entities;
"lib/errors": typeof lib_errors;
"lib/imageAssets": typeof lib_imageAssets;
"lib/lineupItems": typeof lib_lineupItems;
"lib/opTypes": typeof lib_opTypes;
"lib/payloadValidators": typeof lib_payloadValidators;
"lib/profile": typeof lib_profile;
Expand Down
2 changes: 0 additions & 2 deletions convex/error_codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
"INVALID_PAYLOAD",
"INVITE_EXPIRED",
"INVITE_REVOKED",
"LINEUP_END_IN_USE",
"LINEUP_LINK_END_MISSING",
"LINEUP_PAGE_MISMATCH",
"LINEUP_STRATEGY_MISMATCH",
"MISSING_ADD_ELEMENT_ARGS",
Expand Down
107,807 changes: 40,853 additions & 66,954 deletions convex/function_spec.json

Large diffs are not rendered by default.

65 changes: 26 additions & 39 deletions convex/imageAssetLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import { markAssetReferencesReady } from "./lib/assetReferences";
import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol";
import { PAGE_TRASH_RETENTION_MS } from "./lib/entities";
import schema from "./schema";
import { insertElement, insertLineup } from "./testContent.helpers";
import {
insertElement,
insertLineup,
lineupsPayload,
} from "./testContent.helpers";
import { modules } from "./test.setup";

const ensureCurrentUser = makeFunctionReference<"mutation">(
Expand Down Expand Up @@ -105,19 +109,6 @@ function imagePayload(assetPublicId: string) {
};
}

function lineupPayload(assetPublicId: string) {
return {
kind: "lineupLink" as const,
payloadVersion: 1,
data: {
id: "remaining-lineup",
originId: "origin",
landingId: "landing",
images: [{ id: assetPublicId }],
},
};
}

function mockR2Deletes(statuses: number[] = [204]) {
let callIndex = 0;
const fetchMock = vi.fn(
Expand Down Expand Up @@ -245,41 +236,37 @@ describe("image asset lifecycle", () => {
updatedAt: now,
});
}
// One lineup group whose two lineups each show an image.
await insertLineup(ctx, {
publicId: "lineupLink:remaining-lineup",
publicId: "remaining-lineup",
strategyId: strategy._id,
pageId: pageBId,
payloadKind: "lineupLink",
payloadKind: "lineups",
payloadVersion: 1,
payload: lineupPayload("still-used"),
payload: lineupsPayload("remaining-lineup", {
origins: [{ id: "origin" }],
landings: [{ id: "landing" }, { id: "other-landing" }],
links: [
{
id: "remaining-lineup",
originId: "origin",
landingId: "landing",
images: [{ id: "still-used" }],
},
{
id: "remaining-link",
originId: "origin",
landingId: "other-landing",
images: [{ id: "still-used-by-link", fileExtension: ".png" }],
},
],
}),
sortIndex: 0,
revision: 1,
deleted: false,
createdAt: now,
updatedAt: now,
});
await insertLineup(ctx, {
publicId: "lineupLink:remaining-link",
strategyId: strategy._id,
pageId: pageBId,
payloadKind: "lineupLink",
payloadVersion: 1,
payload: {
kind: "lineupLink",
payloadVersion: 1,
data: {
id: "remaining-link",
originId: "origin",
landingId: "landing",
images: [{ id: "still-used-by-link", fileExtension: ".png" }],
},
},
sortIndex: 1,
revision: 1,
deleted: false,
createdAt: now,
updatedAt: now,
});
await insertElement(ctx, {
publicId: "remaining-image-element",
strategyId: strategy._id,
Expand Down
7 changes: 6 additions & 1 deletion convex/lib/cloudProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import { v } from "convex/values";
// public query that returns element or lineup payloads, and the shell the
// editor opens with, takes the protocol too: a client that cannot read a
// row must not see one.
export const CURRENT_CLOUD_PROTOCOL_VERSION = 4;
// 5: one row per lineup group, of kind "lineups": the lineups on a page
// joined through shared spots, with their origins, landings and links,
// replacing the origin, landing and link rows of 4. A client on 4 cannot
// read the new rows and writes rows the server no longer takes, so it is
// refused until it reloads into 5.
export const CURRENT_CLOUD_PROTOCOL_VERSION = 5;
export const MAX_CLOUD_OPERATION_BYTES = 900 * 1024;
export const MAX_CLOUD_ARRAY_ENTRIES = 8_000;
export const CLOUD_OPERATION_TOO_LARGE_MESSAGE =
Expand Down
2 changes: 0 additions & 2 deletions convex/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ export const errorCodes = [
"INVALID_PAYLOAD",
"INVITE_EXPIRED",
"INVITE_REVOKED",
"LINEUP_END_IN_USE",
"LINEUP_LINK_END_MISSING",
"LINEUP_PAGE_MISMATCH",
"LINEUP_STRATEGY_MISMATCH",
"MISSING_ADD_ELEMENT_ARGS",
Expand Down
38 changes: 14 additions & 24 deletions convex/lib/imageAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,40 +52,30 @@ export function collectAssetIdFromElementPayload(
return typeof payload.data.id === "string" ? payload.data.id : null;
}

/// The images a lineup group shows: links[*].images[*].id, across every
/// lineup in it. Its origins and landings hold none.
export function collectAssetIdsFromLineupPayload(
payload: Doc<"lineups">["payload"],
): Set<string> {
const assetIds = new Set<string>();

const addImages = (rawImages: unknown) => {
if (!Array.isArray(rawImages)) {
return;
}
for (const image of rawImages) {
if (
typeof image === "object" &&
image !== null &&
typeof (image as { id?: unknown }).id === "string"
) {
assetIds.add((image as { id: string }).id);
const links = payload.data.links;
if (!Array.isArray(links)) return assetIds;
for (const link of links) {
const images = isObject(link) ? link.images : undefined;
if (!Array.isArray(images)) continue;
for (const image of images) {
if (isObject(image) && typeof image.id === "string") {
assetIds.add(image.id);
}
}
};

switch (payload.kind) {
case "lineupLink":
// A link holds its images: data.images[*].id. Origins and landings
// hold none.
addImages(payload.data.images);
break;
case "lineupOrigin":
case "lineupLanding":
break;
}

return assetIds;
}

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function collectReferencedAssetIds(
elements: Doc<"elements">[],
lineups: Doc<"lineups">[],
Expand Down
95 changes: 95 additions & 0 deletions convex/lib/lineupItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
import { errorWithCode } from "./errors";

type LineupPayload = Doc<"lineups">["payload"];

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/// The lineups and spots a lineup group row holds, each kind of id in its
/// own namespace (a landing may share its lineup's id).
export function lineupGroupItems(payload: LineupPayload): Set<string> {
const items = new Set<string>();
for (const [field, kind] of [
["origins", "origin"],
["landings", "landing"],
["links", "link"],
] as const) {
const entries = payload.data[field];
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (isRecord(entry) && typeof entry.id === "string") {
items.add(`${kind}:${entry.id}`);
}
}
}
return items;
}

/// Brings the lineupItems rows of one lineup group in step with it: one per
/// lineup and spot while the group is live, none otherwise. Call after every
/// write of a lineup row, with null once the row itself is gone.
export async function syncLineupItems(
ctx: MutationCtx,
lineupId: Id<"lineups">,
lineup: Pick<Doc<"lineups">, "pageId" | "deleted" | "payload"> | null,
): Promise<void> {
const existing = await ctx.db
.query("lineupItems")
.withIndex("by_lineupId", (q) => q.eq("lineupId", lineupId))
.collect();
const wanted =
lineup === null || lineup.deleted
? new Set<string>()
: lineupGroupItems(lineup.payload);
for (const row of existing) {
if (lineup !== null && wanted.delete(row.item)) {
if (row.pageId !== lineup.pageId) {
await ctx.db.patch(row._id, { pageId: lineup.pageId });
}
continue;
}
// An item the group no longer holds, a second row for one, or a row
// that is gone.
await ctx.db.delete(row._id);
}
if (lineup === null) return;
for (const item of wanted) {
await ctx.db.insert("lineupItems", {
pageId: lineup.pageId,
lineupId,
item,
});
}
}

/// Refuses a lineup group row ([lineupId], or a row about to be inserted
/// when null) that would hold a lineup or spot another live row of its page
/// holds. Clients never write one: a lineup stays in its group, and a new
/// one is aimed at one existing spot at most. Two rows sharing one would
/// each carry their own copy of it, which can drift apart, so the write
/// fails loudly instead. Reads only the small lineupItems rows, never the
/// lineup rows, whose image lists can be large.
export async function assertLineupGroupAlone(
ctx: MutationCtx,
pageId: Id<"pages">,
lineupId: Id<"lineups"> | null,
payload: LineupPayload,
): Promise<void> {
for (const item of lineupGroupItems(payload)) {
const holders = await ctx.db
.query("lineupItems")
.withIndex("by_pageId_and_item", (q) =>
q.eq("pageId", pageId).eq("item", item),
)
.take(2);
if (holders.some((holder) => holder.lineupId !== lineupId)) {
throw errorWithCode(
"INVALID_LINEUP_PAYLOAD_DATA",
"A lineup or spot in this group is already in another group",
);
}
}
}
8 changes: 6 additions & 2 deletions convex/lib/opTypes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { v, type Infer } from "convex/values";
import {
elementPayloadValidator,
lineupOpPayloadValidator,
lineupPayloadValidator,
mapThemePaletteValidator,
pagePayloadValidator,
Expand Down Expand Up @@ -97,7 +98,7 @@ const lineupAddOpValidator = v.object({
type: v.literal("lineup.add"),
lineupPublicId: v.string(),
pagePublicId: v.string(),
payload: lineupPayloadValidator,
payload: lineupOpPayloadValidator,
sortIndex: v.number(),
expectedLineupRevision: v.optional(v.number()),
});
Expand All @@ -107,7 +108,7 @@ const lineupPatchOpValidator = v.object({
type: v.literal("lineup.patch"),
lineupPublicId: v.string(),
pagePublicId: v.optional(v.string()),
payload: v.optional(lineupPayloadValidator),
payload: v.optional(lineupOpPayloadValidator),
sortIndex: v.optional(v.number()),
expectedLineupRevision: v.number(),
});
Expand Down Expand Up @@ -150,6 +151,9 @@ export type StrategyOp = Infer<typeof strategyOpValidator>;

export const opRejectionReasonValidator = v.union(
v.literal("already_exists"),
// A patch or reorder of a row a teammate deleted: only an add with the
// tombstone's revision brings it back.
v.literal("deleted"),
v.literal("element_strategy_mismatch"),
v.literal("lineup_strategy_mismatch"),
v.literal("missing_expected_revision"),
Expand Down
31 changes: 22 additions & 9 deletions convex/lib/payloadValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,29 @@ export const elementPayloadValidator = v.union(
}),
);

// A page's lineups are a graph, stored one row per entity: an origin (the
// agent), a landing (the ability) and a link between them (name, video,
// notes, images). Several links may share one landing.
export const lineupPayloadKindValidator = v.union(
v.literal("lineupOrigin"),
v.literal("lineupLanding"),
v.literal("lineupLink"),
);
// A lineup group is one row: the lineups on one page joined through shared
// spots, with each spot stored once. data holds the group's id (the row's
// key) and its origins (each placing an agent), landings (each placing an
// ability) and links (one per lineup: which origin to which landing, and its
// name, video, notes and images). See assertLineupPayload in ops.ts for the
// rules a row must meet.
export const lineupPayloadKindValidator = v.literal("lineups");
export const LINEUPS_PAYLOAD_VERSION = 1;

export const lineupPayloadValidator = v.object({
kind: v.literal("lineups"),
payloadVersion: v.number(),
data: cloudJsonObjectValidator,
});

export const lineupPayloadValidator = v.union(
// What a lineup op may carry as an argument: a lineup group, or a row of the
// graph clients on protocol 4 wrote. Convex checks arguments before
// the handler runs, so refusing the old kinds here would answer an old
// client with a validation error instead of CLIENT_UPGRADE_REQUIRED from the
// protocol gate. The handler refuses them (see assertLineupPayload in
// ops.ts) and storage takes only lineup groups.
export const lineupOpPayloadValidator = v.union(
lineupPayloadValidator,
v.object({
kind: v.literal("lineupOrigin"),
payloadVersion: v.number(),
Expand Down
Loading
Loading