diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 944d8d75..b13ba136 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,23 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install Convex Dependencies + shell: pwsh + run: npm ci + + - name: Check Convex Types + shell: pwsh + run: npx tsc --noEmit + + - name: Run Convex Tests + shell: pwsh + run: npm run test:convex + - uses: dart-lang/setup-dart@v1 - name: Cache Pub Packages diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..e951f111 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,51 @@ +# Icarus + +A desktop-first app for creating and sharing Valorant map strategies: +interactive map drawing, agent/ability placement, lineups, and exports. + +## Language + +**Strategy**: +One Valorant map plan — the top-level document a user creates, containing +pages, a map choice, and a theme. This is the domain object; do not name code +abstractions "…Strategy" (GoF sense) unless they operate on this object. +_Avoid_: plan, document, project + +**Page**: +One frame of a strategy: the agents, abilities, drawings, text, images, and +utilities shown at a moment in the plan. Ordered within a strategy. In +user-facing video-export copy, a page shown in sequence is called a "step". +_Avoid_: slide, scene, frame + +**Step duration**: +How long each included page is held on screen in an exported video. One +global value per export. +_Avoid_: page duration, hold time + +**Page transition**: +The animated change between two pages: widgets move, morph, appear, or +disappear; freehand drawings and images fade in early. +_Avoid_: page switch animation + +**Transition entry**: +One widget's role in a page transition — it moves, appears, or disappears. +_Avoid_: transition item + +**Agent path**: +The curved route an agent travels along during a page transition. +_Avoid_: movement path, trajectory + +**Video export**: +Rendering a chosen subset of a strategy's pages, in order, into an .mp4 — +each page held for the step duration with full-fidelity page transitions +between them. +_Avoid_: video sequencing, movie export + +**Lineup**: +A saved ability setup (position/aim reference) attached to a page, grouped +into lineup groups. + +**.ica file**: +Icarus's zip-based strategy interchange format for import/export of whole +strategies. Unrelated to video export. +_Avoid_: archive (ambiguous with library backups) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 4dced5e7..d1628270 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as crons from "../crons.js"; import type * as elements from "../elements.js"; import type * as folders from "../folders.js"; import type * as health from "../health.js"; @@ -17,14 +18,19 @@ import type * as lib_auth from "../lib/auth.js"; 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_opTypes from "../lib/opTypes.js"; import type * as lib_payloadValidators from "../lib/payloadValidators.js"; import type * as lib_r2 from "../lib/r2.js"; +import type * as lib_snapshotSerialization from "../lib/snapshotSerialization.js"; import type * as lineups from "../lineups.js"; +import type * as maintenance from "../maintenance.js"; import type * as ops from "../ops.js"; +import type * as page from "../page.js"; import type * as pages from "../pages.js"; import type * as shares from "../shares.js"; import type * as strategies from "../strategies.js"; +import type * as strategy from "../strategy.js"; import type * as users from "../users.js"; import type { @@ -34,6 +40,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + crons: typeof crons; elements: typeof elements; folders: typeof folders; health: typeof health; @@ -43,14 +50,19 @@ declare const fullApi: ApiFromModules<{ "lib/cloudProtocol": typeof lib_cloudProtocol; "lib/entities": typeof lib_entities; "lib/errors": typeof lib_errors; + "lib/imageAssets": typeof lib_imageAssets; "lib/opTypes": typeof lib_opTypes; "lib/payloadValidators": typeof lib_payloadValidators; "lib/r2": typeof lib_r2; + "lib/snapshotSerialization": typeof lib_snapshotSerialization; lineups: typeof lineups; + maintenance: typeof maintenance; ops: typeof ops; + page: typeof page; pages: typeof pages; shares: typeof shares; strategies: typeof strategies; + strategy: typeof strategy; users: typeof users; }>; diff --git a/convex/lib/canonicalValues.ts b/convex/lib/canonicalValues.ts new file mode 100644 index 00000000..99be643d --- /dev/null +++ b/convex/lib/canonicalValues.ts @@ -0,0 +1,24 @@ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function normalizeComparableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalizeComparableValue); + if (isRecord(value)) { + const result: Record = {}; + for (const key of Object.keys(value).sort()) { + if (value[key] !== undefined) { + result[key] = normalizeComparableValue(value[key]); + } + } + return result; + } + return value; +} + +export function valuesEqual(left: unknown, right: unknown): boolean { + return ( + JSON.stringify(normalizeComparableValue(left)) === + JSON.stringify(normalizeComparableValue(right)) + ); +} diff --git a/convex/lib/cloudProtocol.ts b/convex/lib/cloudProtocol.ts index a99b5529..852e9b62 100644 --- a/convex/lib/cloudProtocol.ts +++ b/convex/lib/cloudProtocol.ts @@ -1,11 +1,10 @@ import { clientUpgradeRequiredError } from "./errors"; -export const CURRENT_CLOUD_PROTOCOL_VERSION = 1; -export const MIN_CLOUD_PROTOCOL_VERSION = 1; +export const CURRENT_CLOUD_PROTOCOL_VERSION = 2; +export const MIN_CLOUD_PROTOCOL_VERSION = 2; export function assertSupportedCloudProtocol(clientProtocolVersion: number): void { if (clientProtocolVersion < MIN_CLOUD_PROTOCOL_VERSION) { throw clientUpgradeRequiredError(); } } - diff --git a/convex/lib/entities.ts b/convex/lib/entities.ts index cf950f5c..f8ecf332 100644 --- a/convex/lib/entities.ts +++ b/convex/lib/entities.ts @@ -88,5 +88,16 @@ export function sortByNumberField>( input: T[], field: keyof T, ): T[] { - return [...input].sort((a, b) => Number(a[field] ?? 0) - Number(b[field] ?? 0)); + return [...input].sort((a, b) => { + const fieldDifference = Number(a[field] ?? 0) - Number(b[field] ?? 0); + if (fieldDifference !== 0) return fieldDifference; + + const leftPublicId = typeof a.publicId === "string" ? a.publicId : ""; + const rightPublicId = typeof b.publicId === "string" ? b.publicId : ""; + return leftPublicId.localeCompare(rightPublicId); + }); +} + +export function clampPageIndex(index: number, maximum: number): number { + return Math.max(0, Math.min(Math.trunc(index), maximum)); } diff --git a/convex/lib/errors.ts b/convex/lib/errors.ts index ad17b1ea..933f531d 100644 --- a/convex/lib/errors.ts +++ b/convex/lib/errors.ts @@ -27,6 +27,9 @@ export type ErrorCode = | "MISSING_PAGE_PUBLIC_ID" | "NOT_FOUND" | "PAGE_STRATEGY_MISMATCH" + | "INVALID_PAGE_CONTENT_COUNT" + | "PAGE_DESCRIPTOR_REQUIRES_PAGE_OP" + | "PAGE_SETTINGS_REQUIRE_PAGE_CONTENT" | "R2_OBJECT_KEY_MISMATCH" | "SHARE_LINK_REVOKED" | "UNAUTHENTICATED" diff --git a/convex/lib/opTypes.ts b/convex/lib/opTypes.ts index 1643d49d..e0c35df4 100644 --- a/convex/lib/opTypes.ts +++ b/convex/lib/opTypes.ts @@ -17,6 +17,7 @@ export const opKindValidator = v.union( export const entityTypeValidator = v.union( v.literal("strategy"), v.literal("page"), + v.literal("pageContent"), v.literal("element"), v.literal("lineup"), ); @@ -37,5 +38,4 @@ export const strategyOpValidator = v.object({ ), sortIndex: v.optional(v.number()), expectedRevision: v.optional(v.number()), - expectedSequence: v.optional(v.number()), }); diff --git a/convex/lib/snapshotSerialization.ts b/convex/lib/snapshotSerialization.ts new file mode 100644 index 00000000..cd40b354 --- /dev/null +++ b/convex/lib/snapshotSerialization.ts @@ -0,0 +1,80 @@ +import type { Doc } from "../_generated/dataModel"; + +export function serializeStrategyHeader( + strategy: Doc<"strategies">, + role: "owner" | "editor" | "viewer", +) { + return { + publicId: strategy.publicId, + name: strategy.name, + mapData: strategy.mapData, + revision: strategy.revision, + createdAt: strategy.createdAt, + updatedAt: strategy.updatedAt, + themeProfileId: strategy.themeProfileId ?? null, + themeOverridePalette: strategy.themeOverridePalette ?? null, + role, + }; +} + +export function serializePageDescriptor( + strategyPublicId: string, + page: Doc<"pages">, +) { + return { + publicId: page.publicId, + strategyPublicId, + name: page.name, + sortIndex: page.sortIndex, + isAttack: page.isAttack, + revision: page.revision, + createdAt: page.createdAt, + updatedAt: page.updatedAt, + }; +} + +export function serializePageContent(pageContent: Doc<"pageContents">) { + return { + settings: pageContent.settings ?? null, + revision: pageContent.revision, + createdAt: pageContent.createdAt, + updatedAt: pageContent.updatedAt, + }; +} + +export function serializeElement( + strategyPublicId: string, + pagePublicId: string, + element: Doc<"elements">, +) { + return { + publicId: element.publicId, + strategyPublicId, + pagePublicId, + elementType: element.elementType, + payload: element.payload, + sortIndex: element.sortIndex, + revision: element.revision, + deleted: element.deleted, + createdAt: element.createdAt, + updatedAt: element.updatedAt, + }; +} + +export function serializeLineup( + strategyPublicId: string, + pagePublicId: string, + lineup: Doc<"lineups">, +) { + return { + publicId: lineup.publicId, + strategyPublicId, + pagePublicId, + payload: lineup.payload, + sortIndex: lineup.sortIndex, + revision: lineup.revision, + deleted: lineup.deleted, + createdAt: lineup.createdAt, + updatedAt: lineup.updatedAt, + }; +} diff --git a/convex/ops.ts b/convex/ops.ts index 8c909abb..7f3a56ac 100644 --- a/convex/ops.ts +++ b/convex/ops.ts @@ -1,40 +1,20 @@ import { mutation, type MutationCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; -import type { Id } from "./_generated/dataModel"; +import type { Doc, Id } from "./_generated/dataModel"; import { assertStrategyRole } from "./lib/auth"; import { - getElementByPublicId, - getLineupByPublicId, - getPageByPublicId, + clampPageIndex, getStrategyByPublicId, + sortByNumberField, } from "./lib/entities"; import { strategyOpValidator } from "./lib/opTypes"; import { assertSupportedCloudProtocol } from "./lib/cloudProtocol"; -import type { Doc } from "./_generated/dataModel"; +import { valuesEqual } from "./lib/canonicalValues"; import { errorWithCode, invalidPayloadError } from "./lib/errors"; import { purgeDeletedPageOrphansRef } from "./maintenance"; -async function incrementSequence(ctx: any, strategy: any): Promise { - const nextSequence = strategy.sequence + 1; - const now = Date.now(); - await ctx.db.patch(strategy._id, { - sequence: nextSequence, - updatedAt: now, - }); - return { - ...strategy, - sequence: nextSequence, - updatedAt: now, - }; -} - type ElementPayload = Doc<"elements">["payload"]; type LineupPayload = Doc<"lineups">["payload"]; -type ReplayEntityTable = "elements" | "lineups"; -type ReplayEntitySnapshot = { - revision: number; - payload: ElementPayload | LineupPayload; -}; type StrategyPatchPayload = { name?: string; mapData?: string; @@ -45,32 +25,31 @@ type StrategyPatchPayload = { }; type PagePayload = { name?: string; - settings?: Doc<"pages">["settings"]; + settings?: Doc<"pageContents">["settings"]; isAttack?: boolean; }; - -async function getReplayEntitySnapshot( - ctx: MutationCtx, - tableName: ReplayEntityTable, - entityPublicId: string, - strategyId: Id<"strategies">, -): Promise { - const entity = - tableName === "elements" - ? await ctx.db - .query("elements") - .withIndex("by_publicId", (q) => q.eq("publicId", entityPublicId)) - .first() - : await ctx.db - .query("lineups") - .withIndex("by_publicId", (q) => q.eq("publicId", entityPublicId)) - .first(); - - if (entity === null || entity.strategyId !== strategyId) { - return null; - } - return { revision: entity.revision, payload: entity.payload }; -} +type StrategyOp = { + opId: string; + kind: "add" | "move" | "patch" | "delete" | "reorder"; + entityType: "strategy" | "page" | "pageContent" | "element" | "lineup"; + entityPublicId?: string; + pagePublicId?: string; + payload?: unknown; + sortIndex?: number; + expectedRevision?: number; +}; +type TargetSnapshot = { + revision: number; + payload: unknown; +}; +type OperationResult = { + status: "ack" | "reject"; + reason?: string; + appliedRevision?: number; + latestRevision?: number; + latestPayload?: unknown; + eventPageId?: Id<"pages">; +}; function isRecord(payload: unknown): payload is Record { return ( @@ -98,27 +77,18 @@ const strategyPatchPayloadKeys = new Set([ "themeOverridePalette", "clearThemeOverridePalette", ]); - const pagePayloadKeys = new Set(["name", "settings", "isAttack"]); function assertStrategyPatchPayload(payload: unknown): StrategyPatchPayload { - if (payload === undefined) { - return {}; - } - if (!isRecord(payload)) { - throw invalidPayloadError("Invalid strategy payload"); - } + if (payload === undefined) return {}; + if (!isRecord(payload)) throw invalidPayloadError("Invalid strategy payload"); assertKnownPayloadKeys(payload, strategyPatchPayloadKeys, "strategy"); return payload as StrategyPatchPayload; } function assertPagePayload(payload: unknown): PagePayload { - if (payload === undefined) { - return {}; - } - if (!isRecord(payload)) { - throw invalidPayloadError("Invalid page payload"); - } + if (payload === undefined) return {}; + if (!isRecord(payload)) throw invalidPayloadError("Invalid page payload"); assertKnownPayloadKeys(payload, pagePayloadKeys, "page"); return payload as PagePayload; } @@ -128,8 +98,6 @@ function assertElementPayload(payload: unknown): ElementPayload { throw errorWithCode("MISSING_ELEMENT_PAYLOAD", "Missing element payload"); } const kind = payload.kind; - const payloadVersion = payload.payloadVersion; - const data = payload.data; if ( kind !== "agent" && kind !== "ability" && @@ -138,16 +106,31 @@ function assertElementPayload(payload: unknown): ElementPayload { kind !== "image" && kind !== "utility" ) { - throw errorWithCode("INVALID_ELEMENT_PAYLOAD_KIND", "Invalid element payload kind"); + throw errorWithCode( + "INVALID_ELEMENT_PAYLOAD_KIND", + "Invalid element payload kind", + ); } - if (typeof payloadVersion !== "number") { - throw errorWithCode("INVALID_ELEMENT_PAYLOAD_VERSION", "Invalid element payload version"); + if (typeof payload.payloadVersion !== "number") { + throw errorWithCode( + "INVALID_ELEMENT_PAYLOAD_VERSION", + "Invalid element payload version", + ); } - if (!isRecord(data)) { - throw errorWithCode("INVALID_ELEMENT_PAYLOAD_DATA", "Invalid element payload data"); + if (!isRecord(payload.data)) { + throw errorWithCode( + "INVALID_ELEMENT_PAYLOAD_DATA", + "Invalid element payload data", + ); } - if (typeof data.elementType === "string" && data.elementType !== kind) { - throw errorWithCode("ELEMENT_TYPE_PAYLOAD_KIND_MISMATCH", "elementType_payloadKind_mismatch"); + if ( + typeof payload.data.elementType === "string" && + payload.data.elementType !== kind + ) { + throw errorWithCode( + "ELEMENT_TYPE_PAYLOAD_KIND_MISMATCH", + "elementType_payloadKind_mismatch", + ); } return payload as ElementPayload; } @@ -157,76 +140,886 @@ function assertLineupPayload(payload: unknown): LineupPayload { throw errorWithCode("MISSING_LINEUP_PAYLOAD", "Missing lineup payload"); } if (payload.kind !== "lineupGroup") { - throw errorWithCode("INVALID_LINEUP_PAYLOAD_KIND", "Invalid lineup payload kind"); + throw errorWithCode( + "INVALID_LINEUP_PAYLOAD_KIND", + "Invalid lineup payload kind", + ); } if (typeof payload.payloadVersion !== "number") { - throw errorWithCode("INVALID_LINEUP_PAYLOAD_VERSION", "Invalid lineup payload version"); + throw errorWithCode( + "INVALID_LINEUP_PAYLOAD_VERSION", + "Invalid lineup payload version", + ); } if (!isRecord(payload.data)) { - throw errorWithCode("INVALID_LINEUP_PAYLOAD_DATA", "Invalid lineup payload data"); + throw errorWithCode( + "INVALID_LINEUP_PAYLOAD_DATA", + "Invalid lineup payload data", + ); } return payload as LineupPayload; } -function normalizeComparableValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(normalizeComparableValue); - } - if (isRecord(value)) { - const result: Record = {}; - for (const key of Object.keys(value).sort()) { - const child = value[key]; - if (child !== undefined) { - result[key] = normalizeComparableValue(child); - } - } - return result; - } - return value; -} - -function valuesEqual(left: unknown, right: unknown): boolean { - return ( - JSON.stringify(normalizeComparableValue(left)) === - JSON.stringify(normalizeComparableValue(right)) - ); -} - function setIfChanged( patch: Record, key: string, currentValue: unknown, nextValue: unknown, ): void { - if (!valuesEqual(currentValue, nextValue)) { - patch[key] = nextValue; + if (!valuesEqual(currentValue, nextValue)) patch[key] = nextValue; +} + +function requireExpectedRevision(op: StrategyOp, currentRevision: number) { + if (op.expectedRevision === undefined) { + return { status: "reject" as const, reason: "missing_expected_revision" }; } + if (op.expectedRevision !== currentRevision) { + return { status: "reject" as const, reason: "revision_mismatch" }; + } + return null; } -function hasChanges(patch: Record): boolean { - return Object.keys(patch).length > 0; +function strategyPayload(strategy: Doc<"strategies">) { + return { + name: strategy.name, + mapData: strategy.mapData, + themeProfileId: strategy.themeProfileId ?? null, + themeOverridePalette: strategy.themeOverridePalette ?? null, + }; } -async function patchStrategyAndIncrement( - ctx: any, - strategy: any, - patch: Record, -): Promise { - const nextSequence = strategy.sequence + 1; - const now = Date.now(); - await ctx.db.patch(strategy._id, { - ...patch, - sequence: nextSequence, - updatedAt: now, - }); +function pagePayload(page: Doc<"pages">) { + return { + name: page.name, + isAttack: page.isAttack, + sortIndex: page.sortIndex, + }; +} + +async function getPageByPublicIdOrNull( + ctx: MutationCtx, + publicId: string, +): Promise | null> { + return await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", publicId)) + .first(); +} + +async function getElementByPublicIdOrNull( + ctx: MutationCtx, + publicId: string, +): Promise | null> { + return await ctx.db + .query("elements") + .withIndex("by_publicId", (q) => q.eq("publicId", publicId)) + .first(); +} + +async function getLineupByPublicIdOrNull( + ctx: MutationCtx, + publicId: string, +): Promise | null> { + return await ctx.db + .query("lineups") + .withIndex("by_publicId", (q) => q.eq("publicId", publicId)) + .first(); +} + +async function getPageContent( + ctx: MutationCtx, + pageId: Id<"pages">, +): Promise> { + const rows = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", pageId)) + .take(2); + if (rows.length !== 1) { + throw errorWithCode( + "INVALID_PAGE_CONTENT_COUNT", + "Each page must have exactly one page content row", + ); + } + return rows[0]!; +} + +async function getTargetSnapshot( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, +): Promise { + if ( + op.entityType === "strategy" || + (op.entityType === "page" && op.kind !== "patch") + ) { + return { revision: strategy.revision, payload: strategyPayload(strategy) }; + } + const publicId = op.entityPublicId ?? op.pagePublicId; + if (publicId === undefined) return null; + if (op.entityType === "page") { + const page = await getPageByPublicIdOrNull(ctx, publicId); + if (page === null || page.strategyId !== strategy._id) return null; + return { revision: page.revision, payload: pagePayload(page) }; + } + if (op.entityType === "pageContent") { + const page = await getPageByPublicIdOrNull(ctx, publicId); + if (page === null || page.strategyId !== strategy._id) return null; + const content = await getPageContent(ctx, page._id); + return { + revision: content.revision, + payload: { settings: content.settings ?? null }, + }; + } + if (op.entityType === "element") { + const element = await getElementByPublicIdOrNull(ctx, publicId); + if (element === null || element.strategyId !== strategy._id) return null; + return { revision: element.revision, payload: element.payload }; + } + const lineup = await getLineupByPublicIdOrNull(ctx, publicId); + if (lineup === null || lineup.strategyId !== strategy._id) return null; + return { revision: lineup.revision, payload: lineup.payload }; +} + +function rejected( + reason: string, + snapshot?: TargetSnapshot | null, + eventPageId?: Id<"pages">, +): OperationResult { + return { + status: "reject", + reason, + latestRevision: snapshot?.revision, + latestPayload: snapshot?.payload, + eventPageId, + }; +} + +function noop(revision?: number, eventPageId?: Id<"pages">): OperationResult { return { + status: "ack", + reason: "noop", + appliedRevision: revision, + latestRevision: revision, + eventPageId, + }; +} + +async function applyStrategyOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, +): Promise<{ strategy: Doc<"strategies">; result: OperationResult }> { + if (op.kind !== "patch") { + throw errorWithCode("UNSUPPORTED_OP", "Unsupported strategy op"); + } + const payload = assertStrategyPatchPayload(op.payload); + const patch: Record = {}; + if (payload.name !== undefined) { + setIfChanged(patch, "name", strategy.name, payload.name); + } + if (payload.mapData !== undefined) { + setIfChanged(patch, "mapData", strategy.mapData, payload.mapData); + } + if (payload.themeProfileId !== undefined) { + setIfChanged( + patch, + "themeProfileId", + strategy.themeProfileId, + payload.themeProfileId, + ); + } + if (payload.clearThemeProfileId === true) { + setIfChanged(patch, "themeProfileId", strategy.themeProfileId, undefined); + } + if (payload.themeOverridePalette !== undefined) { + setIfChanged( + patch, + "themeOverridePalette", + strategy.themeOverridePalette, + payload.themeOverridePalette, + ); + } + if (payload.clearThemeOverridePalette === true) { + setIfChanged( + patch, + "themeOverridePalette", + strategy.themeOverridePalette, + undefined, + ); + } + if (Object.keys(patch).length === 0) { + return { strategy, result: noop(strategy.revision) }; + } + const mismatch = requireExpectedRevision(op, strategy.revision); + if (mismatch !== null) { + return { + strategy, + result: rejected(mismatch.reason, { + revision: strategy.revision, + payload: strategyPayload(strategy), + }), + }; + } + + const revision = strategy.revision + 1; + const updatedAt = Date.now(); + await ctx.db.patch(strategy._id, { ...patch, revision, updatedAt }); + const updated = { ...strategy, ...patch, - sequence: nextSequence, - updatedAt: now, + revision, + updatedAt, + } as Doc<"strategies">; + return { + strategy: updated, + result: { status: "ack", appliedRevision: revision }, }; } +async function applyPageOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, +): Promise<{ strategy: Doc<"strategies">; result: OperationResult }> { + const publicId = op.entityPublicId ?? op.pagePublicId; + if (publicId === undefined) { + throw errorWithCode("MISSING_PAGE_ID", "Missing page id"); + } + const existing = await getPageByPublicIdOrNull(ctx, publicId); + + if (op.kind === "add") { + const payload = assertPagePayload(op.payload); + if (existing !== null) { + if (existing.strategyId !== strategy._id) { + return { strategy, result: rejected("page_strategy_mismatch") }; + } + const content = await getPageContent(ctx, existing._id); + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + const desiredSortIndex = clampPageIndex( + op.sortIndex ?? 0, + Math.max(0, pages.length - 1), + ); + const identical = + existing.name === (payload.name ?? "Page") && + existing.sortIndex === desiredSortIndex && + existing.isAttack === (payload.isAttack ?? true) && + valuesEqual(content.settings, payload.settings); + if (identical) { + return { strategy, result: noop(strategy.revision, existing._id) }; + } + return { + strategy, + result: rejected( + "already_exists", + { revision: strategy.revision, payload: pagePayload(existing) }, + existing._id, + ), + }; + } + const mismatch = requireExpectedRevision(op, strategy.revision); + if (mismatch !== null) { + return { + strategy, + result: rejected(mismatch.reason, { + revision: strategy.revision, + payload: strategyPayload(strategy), + }), + }; + } + + const now = Date.now(); + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + const orderedPages = sortByNumberField(pages, "sortIndex"); + const desiredSortIndex = clampPageIndex( + op.sortIndex ?? 0, + orderedPages.length, + ); + for (let index = 0; index < orderedPages.length; index += 1) { + const page = orderedPages[index]!; + const normalizedIndex = index >= desiredSortIndex ? index + 1 : index; + if (page.sortIndex !== normalizedIndex) { + await ctx.db.patch(page._id, { + sortIndex: normalizedIndex, + revision: page.revision + 1, + updatedAt: now, + }); + } + } + const pageId = await ctx.db.insert("pages", { + publicId, + strategyId: strategy._id, + name: payload.name ?? "Page", + sortIndex: desiredSortIndex, + isAttack: payload.isAttack ?? true, + revision: 1, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("pageContents", { + pageId, + settings: payload.settings, + revision: 1, + createdAt: now, + updatedAt: now, + }); + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { + strategy: { ...strategy, revision, updatedAt: now }, + result: { status: "ack", appliedRevision: revision, eventPageId: pageId }, + }; + } + + if (op.kind === "delete") { + if (existing === null || existing.strategyId !== strategy._id) { + return { strategy, result: noop(strategy.revision) }; + } + const mismatch = requireExpectedRevision(op, strategy.revision); + if (mismatch !== null) { + return { + strategy, + result: rejected( + mismatch.reason, + { revision: strategy.revision, payload: strategyPayload(strategy) }, + existing._id, + ), + }; + } + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + if (pages.length <= 1) { + throw errorWithCode("INVALID_OP", "Cannot delete last page"); + } + const contentRows = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", existing._id)) + .collect(); + for (const content of contentRows) await ctx.db.delete(content._id); + await ctx.db.delete(existing._id); + await ctx.scheduler.runAfter(0, purgeDeletedPageOrphansRef, { + pageId: existing._id, + }); + const now = Date.now(); + const remaining = sortByNumberField( + pages.filter((page) => page._id !== existing._id), + "sortIndex", + ); + for (let index = 0; index < remaining.length; index += 1) { + const page = remaining[index]!; + if (page.sortIndex !== index) { + await ctx.db.patch(page._id, { + sortIndex: index, + revision: page.revision + 1, + updatedAt: now, + }); + } + } + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { + strategy: { ...strategy, revision, updatedAt: now }, + result: { + status: "ack", + appliedRevision: revision, + eventPageId: existing._id, + }, + }; + } + + if (existing === null || existing.strategyId !== strategy._id) { + return { strategy, result: rejected("not_found") }; + } + if (op.kind === "reorder") { + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + const orderedPages = sortByNumberField(pages, "sortIndex"); + const currentIndex = orderedPages.findIndex( + (page) => page._id === existing._id, + ); + const desiredSortIndex = clampPageIndex( + op.sortIndex ?? currentIndex, + Math.max(0, orderedPages.length - 1), + ); + const reorderedPages = orderedPages.filter( + (page) => page._id !== existing._id, + ); + reorderedPages.splice(desiredSortIndex, 0, existing); + const alreadyNormalized = reorderedPages.every( + (page, index) => page.sortIndex === index, + ); + if (currentIndex === desiredSortIndex && alreadyNormalized) { + return { strategy, result: noop(strategy.revision, existing._id) }; + } + const mismatch = requireExpectedRevision(op, strategy.revision); + if (mismatch !== null) { + return { + strategy, + result: rejected( + mismatch.reason, + { revision: strategy.revision, payload: strategyPayload(strategy) }, + existing._id, + ), + }; + } + const now = Date.now(); + for (let index = 0; index < reorderedPages.length; index += 1) { + const page = reorderedPages[index]!; + if (page.sortIndex !== index) { + await ctx.db.patch(page._id, { + sortIndex: index, + revision: page.revision + 1, + updatedAt: now, + }); + } + } + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { + strategy: { ...strategy, revision, updatedAt: now }, + result: { + status: "ack", + appliedRevision: revision, + eventPageId: existing._id, + }, + }; + } + if (op.kind !== "patch") { + throw errorWithCode("UNSUPPORTED_OP", "Unsupported page op"); + } + const payload = assertPagePayload(op.payload); + if (payload.settings !== undefined) { + throw errorWithCode( + "PAGE_SETTINGS_REQUIRE_PAGE_CONTENT", + "Page settings require a pageContent operation", + ); + } + const patch: Record = {}; + if (payload.name !== undefined) { + setIfChanged(patch, "name", existing.name, payload.name); + } + if (payload.isAttack !== undefined) { + setIfChanged(patch, "isAttack", existing.isAttack, payload.isAttack); + } + if (Object.keys(patch).length === 0) { + return { strategy, result: noop(existing.revision, existing._id) }; + } + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return { + strategy, + result: rejected( + mismatch.reason, + { revision: existing.revision, payload: pagePayload(existing) }, + existing._id, + ), + }; + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + ...patch, + revision, + updatedAt: Date.now(), + }); + return { + strategy, + result: { + status: "ack", + appliedRevision: revision, + eventPageId: existing._id, + }, + }; +} + +async function applyPageContentOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, +): Promise { + if (op.kind !== "patch") { + throw errorWithCode("UNSUPPORTED_OP", "Unsupported page content op"); + } + const publicId = op.entityPublicId ?? op.pagePublicId; + if (publicId === undefined) { + throw errorWithCode("MISSING_PAGE_ID", "Missing page id"); + } + const page = await getPageByPublicIdOrNull(ctx, publicId); + if (page === null || page.strategyId !== strategy._id) { + return rejected("not_found"); + } + const payload = assertPagePayload(op.payload); + if (payload.name !== undefined || payload.isAttack !== undefined) { + throw errorWithCode( + "PAGE_DESCRIPTOR_REQUIRES_PAGE_OP", + "Page descriptor fields require a page operation", + ); + } + const content = await getPageContent(ctx, page._id); + if (valuesEqual(content.settings, payload.settings)) { + return noop(content.revision, page._id); + } + const mismatch = requireExpectedRevision(op, content.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { + revision: content.revision, + payload: { settings: content.settings ?? null }, + }, + page._id, + ); + } + const revision = content.revision + 1; + await ctx.db.patch(content._id, { + settings: payload.settings, + revision, + updatedAt: Date.now(), + }); + return { status: "ack", appliedRevision: revision, eventPageId: page._id }; +} + +async function applyElementOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, +): Promise { + const publicId = op.entityPublicId; + if (publicId === undefined) { + throw errorWithCode("MISSING_ENTITY_PUBLIC_ID", "Missing entityPublicId"); + } + const existing = await getElementByPublicIdOrNull(ctx, publicId); + + if (op.kind === "add") { + if (op.pagePublicId === undefined) { + throw errorWithCode("MISSING_PAGE_PUBLIC_ID", "Missing pagePublicId"); + } + const page = await getPageByPublicIdOrNull(ctx, op.pagePublicId); + if (page === null || page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + const payload = assertElementPayload(op.payload); + if (existing !== null) { + if (existing.strategyId !== strategy._id) { + return rejected("element_strategy_mismatch"); + } + if (existing.deleted) { + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + pageId: page._id, + elementType: payload.kind, + payloadKind: payload.kind, + payloadVersion: payload.payloadVersion, + payload, + sortIndex: op.sortIndex ?? 0, + revision, + deleted: false, + updatedAt: Date.now(), + }); + return { + status: "ack", + appliedRevision: revision, + eventPageId: page._id, + }; + } + const identical = + existing.pageId === page._id && + existing.elementType === payload.kind && + valuesEqual(existing.payload, payload) && + existing.sortIndex === (op.sortIndex ?? 0) && + existing.deleted === false; + if (identical) return noop(existing.revision, existing.pageId); + return rejected( + "already_exists", + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const now = Date.now(); + await ctx.db.insert("elements", { + publicId, + strategyId: strategy._id, + pageId: page._id, + elementType: payload.kind, + payloadKind: payload.kind, + payloadVersion: payload.payloadVersion, + payload, + sortIndex: op.sortIndex ?? 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + return { status: "ack", appliedRevision: 1, eventPageId: page._id }; + } + + if (op.kind === "delete") { + if (existing === null || existing.strategyId !== strategy._id) + return noop(); + if (existing.deleted) return noop(existing.revision, existing.pageId); + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + deleted: true, + revision, + updatedAt: Date.now(), + }); + return { + status: "ack", + appliedRevision: revision, + eventPageId: existing.pageId, + }; + } + + if (existing === null || existing.strategyId !== strategy._id) { + return rejected("not_found"); + } + const patch: Record = {}; + let eventPageId = existing.pageId; + if (op.kind === "patch" || op.kind === "move") { + if (op.payload !== undefined) { + const payload = assertElementPayload(op.payload); + if (payload.kind !== existing.elementType) { + throw errorWithCode( + "ELEMENT_TYPE_PAYLOAD_KIND_MISMATCH", + "elementType_payloadKind_mismatch", + ); + } + setIfChanged(patch, "payload", existing.payload, payload); + setIfChanged(patch, "payloadKind", existing.payloadKind, payload.kind); + setIfChanged( + patch, + "payloadVersion", + existing.payloadVersion, + payload.payloadVersion, + ); + } + if (op.sortIndex !== undefined) { + setIfChanged(patch, "sortIndex", existing.sortIndex, op.sortIndex); + } + if (op.pagePublicId !== undefined) { + const page = await getPageByPublicIdOrNull(ctx, op.pagePublicId); + if (page === null || page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + setIfChanged(patch, "pageId", existing.pageId, page._id); + eventPageId = page._id; + } + } else if (op.kind === "reorder") { + setIfChanged( + patch, + "sortIndex", + existing.sortIndex, + op.sortIndex ?? existing.sortIndex, + ); + } else { + throw errorWithCode("UNSUPPORTED_OP", "Unsupported element op"); + } + if (Object.keys(patch).length === 0) { + return noop(existing.revision, eventPageId); + } + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + ...patch, + revision, + updatedAt: Date.now(), + }); + return { status: "ack", appliedRevision: revision, eventPageId }; +} + +async function applyLineupOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, +): Promise { + const publicId = op.entityPublicId; + if (publicId === undefined) { + throw errorWithCode("MISSING_ENTITY_PUBLIC_ID", "Missing entityPublicId"); + } + const existing = await getLineupByPublicIdOrNull(ctx, publicId); + + if (op.kind === "add") { + if (op.pagePublicId === undefined) { + throw errorWithCode("MISSING_PAGE_PUBLIC_ID", "Missing pagePublicId"); + } + const page = await getPageByPublicIdOrNull(ctx, op.pagePublicId); + if (page === null || page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + const payload = assertLineupPayload(op.payload); + if (existing !== null) { + if (existing.strategyId !== strategy._id) { + return rejected("lineup_strategy_mismatch"); + } + if (existing.deleted) { + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + pageId: page._id, + payloadKind: "lineupGroup", + payloadVersion: payload.payloadVersion, + payload, + sortIndex: op.sortIndex ?? 0, + revision, + deleted: false, + updatedAt: Date.now(), + }); + return { + status: "ack", + appliedRevision: revision, + eventPageId: page._id, + }; + } + const identical = + existing.pageId === page._id && + valuesEqual(existing.payload, payload) && + existing.sortIndex === (op.sortIndex ?? 0) && + existing.deleted === false; + if (identical) return noop(existing.revision, existing.pageId); + return rejected( + "already_exists", + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const now = Date.now(); + await ctx.db.insert("lineups", { + publicId, + strategyId: strategy._id, + pageId: page._id, + payloadKind: "lineupGroup", + payloadVersion: payload.payloadVersion, + payload, + sortIndex: op.sortIndex ?? 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + return { status: "ack", appliedRevision: 1, eventPageId: page._id }; + } + + if (op.kind === "delete") { + if (existing === null || existing.strategyId !== strategy._id) + return noop(); + if (existing.deleted) return noop(existing.revision, existing.pageId); + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + deleted: true, + revision, + updatedAt: Date.now(), + }); + return { + status: "ack", + appliedRevision: revision, + eventPageId: existing.pageId, + }; + } + + if (existing === null || existing.strategyId !== strategy._id) { + return rejected("not_found"); + } + const patch: Record = {}; + let eventPageId = existing.pageId; + if (op.kind === "patch" || op.kind === "move") { + if (op.payload !== undefined) { + const payload = assertLineupPayload(op.payload); + setIfChanged(patch, "payload", existing.payload, payload); + setIfChanged(patch, "payloadKind", existing.payloadKind, payload.kind); + setIfChanged( + patch, + "payloadVersion", + existing.payloadVersion, + payload.payloadVersion, + ); + } + if (op.sortIndex !== undefined) { + setIfChanged(patch, "sortIndex", existing.sortIndex, op.sortIndex); + } + if (op.pagePublicId !== undefined) { + const page = await getPageByPublicIdOrNull(ctx, op.pagePublicId); + if (page === null || page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + setIfChanged(patch, "pageId", existing.pageId, page._id); + eventPageId = page._id; + } + } else if (op.kind === "reorder") { + setIfChanged( + patch, + "sortIndex", + existing.sortIndex, + op.sortIndex ?? existing.sortIndex, + ); + } else { + throw errorWithCode("UNSUPPORTED_OP", "Unsupported lineup op"); + } + if (Object.keys(patch).length === 0) { + return noop(existing.revision, eventPageId); + } + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + ...patch, + revision, + updatedAt: Date.now(), + }); + return { status: "ack", appliedRevision: revision, eventPageId }; +} + export const applyBatch = mutation({ args: { strategyPublicId: v.string(), @@ -236,13 +1029,15 @@ export const applyBatch = mutation({ }, handler: async (ctx, args) => { assertSupportedCloudProtocol(args.clientProtocolVersion); - let strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); - const results: Array> = []; - for (const op of args.ops) { + // Outcomes are per operation: accepted changes and visible rejections are + // committed together by this single Convex transaction. One stale op must + // not erase an independent op that the server already accepted. + for (const rawOp of args.ops) { + const op = rawOp as StrategyOp; const existingEvent = await ctx.db .query("operationEvents") .withIndex("by_strategyId_clientId_opId", (q) => @@ -252,688 +1047,70 @@ export const applyBatch = mutation({ .eq("opId", op.opId), ) .first(); - if (existingEvent !== null) { - let latestRevision: number | null = null; - let latestPayload: ElementPayload | LineupPayload | null = null; - - if (op.entityType === "element" && op.entityPublicId !== undefined) { - const snapshot = await getReplayEntitySnapshot( - ctx, - "elements", - op.entityPublicId, - strategy._id, - ); - if (snapshot !== null) { - latestRevision = snapshot.revision; - latestPayload = snapshot.payload; - } - } else if (op.entityType === "lineup" && op.entityPublicId !== undefined) { - const snapshot = await getReplayEntitySnapshot( - ctx, - "lineups", - op.entityPublicId, - strategy._id, - ); - if (snapshot !== null) { - latestRevision = snapshot.revision; - latestPayload = snapshot.payload; - } - } - + const latest = await getTargetSnapshot(ctx, strategy, op); results.push({ opId: op.opId, status: existingEvent.status, reason: existingEvent.reason ?? null, - appliedSequence: existingEvent.appliedSequence ?? null, - expectedSequence: existingEvent.expectedSequence ?? null, appliedRevision: existingEvent.appliedRevision ?? null, expectedRevision: existingEvent.expectedRevision ?? null, - latestSequence: strategy.sequence, - latestRevision, - latestPayload, + latestRevision: latest?.revision ?? null, + latestPayload: latest?.payload ?? null, }); continue; } - let status: "ack" | "reject" = "ack"; - let reason: string | undefined; - let appliedRevision: number | undefined; - let latestRevision: number | undefined; - let latestPayload: ElementPayload | LineupPayload | undefined; - let eventPageId: Id<"pages"> | undefined; - let shouldRecordEvent = true; - const markNoop = (currentRevision?: number) => { - reason = "noop"; - shouldRecordEvent = false; - if (currentRevision !== undefined) { - appliedRevision = currentRevision; - } - }; + let result: OperationResult; try { - if ( - op.expectedSequence !== undefined && - op.expectedSequence !== strategy.sequence - ) { - status = "reject"; - reason = "sequence_mismatch"; - } else if (op.entityType === "strategy") { - if (op.kind !== "patch") { - throw errorWithCode("UNSUPPORTED_OP", "Unsupported strategy op"); - } - - const payload = assertStrategyPatchPayload(op.payload); - const patch: Record = {}; - if (typeof payload.name === "string") { - setIfChanged(patch, "name", strategy.name, payload.name); - } - if (typeof payload.mapData === "string") { - setIfChanged(patch, "mapData", strategy.mapData, payload.mapData); - } - if (typeof payload.themeProfileId === "string") { - setIfChanged( - patch, - "themeProfileId", - strategy.themeProfileId, - payload.themeProfileId, - ); - } - if (payload.clearThemeProfileId === true) { - setIfChanged( - patch, - "themeProfileId", - strategy.themeProfileId, - undefined, - ); - } - if (payload.themeOverridePalette !== undefined) { - setIfChanged( - patch, - "themeOverridePalette", - strategy.themeOverridePalette, - payload.themeOverridePalette, - ); - } - if (payload.clearThemeOverridePalette === true) { - setIfChanged( - patch, - "themeOverridePalette", - strategy.themeOverridePalette, - undefined, - ); - } - - if (hasChanges(patch)) { - strategy = await patchStrategyAndIncrement(ctx, strategy, patch); - } else { - markNoop(); - } + if (op.entityType === "strategy") { + const applied = await applyStrategyOp(ctx, strategy, op); + strategy = applied.strategy; + result = applied.result; } else if (op.entityType === "page") { - if (op.kind === "add") { - const pagePublicId = op.pagePublicId; - if (!pagePublicId) { - throw errorWithCode("MISSING_PAGE_PUBLIC_ID", "Missing pagePublicId"); - } - const payload = assertPagePayload(op.payload); - const now = Date.now(); - const existingPage = await ctx.db - .query("pages") - .withIndex("by_publicId", (q) => q.eq("publicId", pagePublicId)) - .first(); - - if (existingPage !== null) { - if (existingPage.strategyId !== strategy._id) { - throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); - } - eventPageId = existingPage._id; - - const patch: Record = {}; - setIfChanged( - patch, - "name", - existingPage.name, - typeof payload.name === "string" - ? payload.name - : existingPage.name, - ); - setIfChanged( - patch, - "sortIndex", - existingPage.sortIndex, - op.sortIndex ?? existingPage.sortIndex, - ); - setIfChanged( - patch, - "isAttack", - existingPage.isAttack, - typeof payload.isAttack === "boolean" - ? payload.isAttack - : existingPage.isAttack, - ); - setIfChanged( - patch, - "settings", - existingPage.settings, - payload.settings !== undefined - ? payload.settings - : existingPage.settings, - ); - - if (hasChanges(patch)) { - await ctx.db.patch(existingPage._id, { - ...patch, - revision: existingPage.revision + 1, - updatedAt: now, - }); - appliedRevision = existingPage.revision + 1; - } else { - markNoop(existingPage.revision); - } - } else { - const insertedPageId = await ctx.db.insert("pages", { - publicId: pagePublicId, - strategyId: strategy._id, - name: typeof payload.name === "string" ? payload.name : "Page", - sortIndex: op.sortIndex ?? 0, - isAttack: payload.isAttack === false ? false : true, - settings: payload.settings, - revision: 1, - createdAt: now, - updatedAt: now, - }); - eventPageId = insertedPageId; - appliedRevision = 1; - } - - if (shouldRecordEvent) { - strategy = await incrementSequence(ctx, strategy); - } - } else { - const pagePublicId = op.entityPublicId ?? op.pagePublicId; - if (!pagePublicId) { - throw errorWithCode("MISSING_PAGE_ID", "Missing page id"); - } - const page = await getPageByPublicId(ctx, pagePublicId); - if (page.strategyId !== strategy._id) { - throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); - } - eventPageId = page._id; - - latestRevision = page.revision; - - if ( - op.expectedRevision !== undefined && - op.expectedRevision !== page.revision - ) { - status = "reject"; - reason = "revision_mismatch"; - } else if (op.kind === "patch") { - const payload = assertPagePayload(op.payload); - const patch: Record = {}; - if (typeof payload.name === "string") { - setIfChanged(patch, "name", page.name, payload.name); - } - if (payload.settings !== undefined) { - setIfChanged(patch, "settings", page.settings, payload.settings); - } - if (typeof payload.isAttack === "boolean") { - setIfChanged( - patch, - "isAttack", - page.isAttack, - payload.isAttack, - ); - } - if (hasChanges(patch)) { - await ctx.db.patch(page._id, { - ...patch, - revision: page.revision + 1, - updatedAt: Date.now(), - }); - appliedRevision = page.revision + 1; - strategy = await incrementSequence(ctx, strategy); - } else { - markNoop(page.revision); - } - } else if (op.kind === "delete") { - await ctx.db.delete(page._id); - await ctx.scheduler.runAfter(0, purgeDeletedPageOrphansRef, { - pageId: page._id, - }); - appliedRevision = page.revision + 1; - strategy = await incrementSequence(ctx, strategy); - } else if (op.kind === "reorder") { - const nextSortIndex = op.sortIndex ?? page.sortIndex; - if (valuesEqual(page.sortIndex, nextSortIndex)) { - markNoop(page.revision); - } else { - await ctx.db.patch(page._id, { - sortIndex: nextSortIndex, - revision: page.revision + 1, - updatedAt: Date.now(), - }); - appliedRevision = page.revision + 1; - strategy = await incrementSequence(ctx, strategy); - } - } else { - throw errorWithCode("UNSUPPORTED_OP", "Unsupported page op"); - } - } + const applied = await applyPageOp(ctx, strategy, op); + strategy = applied.strategy; + result = applied.result; + } else if (op.entityType === "pageContent") { + result = await applyPageContentOp(ctx, strategy, op); } else if (op.entityType === "element") { - if (op.kind === "add") { - const elementPublicId = op.entityPublicId; - const pagePublicId = op.pagePublicId; - if (!elementPublicId || !pagePublicId || !op.payload) { - throw errorWithCode( - "MISSING_ADD_ELEMENT_ARGS", - "Missing add element args", - ); - } - const page = await getPageByPublicId(ctx, pagePublicId); - if (page.strategyId !== strategy._id) { - throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); - } - eventPageId = page._id; - const payload = assertElementPayload(op.payload); - const elementType = payload.kind; - const now = Date.now(); - const existingElement = await ctx.db - .query("elements") - .withIndex("by_publicId", (q) => q.eq("publicId", elementPublicId)) - .first(); - - if (existingElement !== null) { - if (existingElement.strategyId !== strategy._id) { - throw errorWithCode( - "ELEMENT_STRATEGY_MISMATCH", - "Element strategy mismatch", - ); - } - const patch: Record = {}; - setIfChanged(patch, "pageId", existingElement.pageId, page._id); - setIfChanged( - patch, - "elementType", - existingElement.elementType, - elementType, - ); - setIfChanged( - patch, - "payloadKind", - existingElement.payloadKind, - payload.kind, - ); - setIfChanged( - patch, - "payloadVersion", - existingElement.payloadVersion, - payload.payloadVersion, - ); - setIfChanged(patch, "payload", existingElement.payload, payload); - setIfChanged( - patch, - "sortIndex", - existingElement.sortIndex, - op.sortIndex ?? existingElement.sortIndex, - ); - setIfChanged(patch, "deleted", existingElement.deleted, false); - - if (hasChanges(patch)) { - await ctx.db.patch(existingElement._id, { - ...patch, - revision: existingElement.revision + 1, - updatedAt: now, - }); - appliedRevision = existingElement.revision + 1; - } else { - markNoop(existingElement.revision); - } - } else { - await ctx.db.insert("elements", { - publicId: elementPublicId, - strategyId: strategy._id, - pageId: page._id, - elementType, - payloadKind: payload.kind, - payloadVersion: payload.payloadVersion, - payload, - sortIndex: op.sortIndex ?? 0, - revision: 1, - deleted: false, - createdAt: now, - updatedAt: now, - }); - appliedRevision = 1; - } - if (shouldRecordEvent) { - strategy = await incrementSequence(ctx, strategy); - } - } else { - if (!op.entityPublicId) { - throw errorWithCode("MISSING_ENTITY_PUBLIC_ID", "Missing entityPublicId"); - } - const element = await getElementByPublicId(ctx, op.entityPublicId); - if (element.strategyId !== strategy._id) { - throw errorWithCode( - "ELEMENT_STRATEGY_MISMATCH", - "Element strategy mismatch", - ); - } - eventPageId = element.pageId; - - latestRevision = element.revision; - latestPayload = element.payload; - - if ( - op.expectedRevision !== undefined && - op.expectedRevision !== element.revision - ) { - status = "reject"; - reason = "revision_mismatch"; - } else if (op.kind === "delete") { - if (element.deleted) { - markNoop(element.revision); - } else { - await ctx.db.patch(element._id, { - deleted: true, - revision: element.revision + 1, - updatedAt: Date.now(), - }); - appliedRevision = element.revision + 1; - strategy = await incrementSequence(ctx, strategy); - } - } else if (op.kind === "patch" || op.kind === "move") { - const patch: Record = {}; - if (op.payload !== undefined) { - const payload = assertElementPayload(op.payload); - if (payload.kind !== element.elementType) { - throw errorWithCode( - "ELEMENT_TYPE_PAYLOAD_KIND_MISMATCH", - "elementType_payloadKind_mismatch", - ); - } - setIfChanged(patch, "payload", element.payload, payload); - setIfChanged( - patch, - "payloadKind", - element.payloadKind, - payload.kind, - ); - setIfChanged( - patch, - "payloadVersion", - element.payloadVersion, - payload.payloadVersion, - ); - } - if (op.sortIndex !== undefined) { - setIfChanged( - patch, - "sortIndex", - element.sortIndex, - op.sortIndex, - ); - } - if (op.pagePublicId !== undefined) { - const page = await getPageByPublicId(ctx, op.pagePublicId); - if (page.strategyId !== strategy._id) { - throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); - } - setIfChanged(patch, "pageId", element.pageId, page._id); - eventPageId = page._id; - } - - if (hasChanges(patch)) { - await ctx.db.patch(element._id, { - ...patch, - revision: element.revision + 1, - updatedAt: Date.now(), - }); - appliedRevision = element.revision + 1; - strategy = await incrementSequence(ctx, strategy); - } else { - markNoop(element.revision); - } - } else if (op.kind === "reorder") { - const nextSortIndex = op.sortIndex ?? element.sortIndex; - if (valuesEqual(element.sortIndex, nextSortIndex)) { - markNoop(element.revision); - } else { - await ctx.db.patch(element._id, { - sortIndex: nextSortIndex, - revision: element.revision + 1, - updatedAt: Date.now(), - }); - appliedRevision = element.revision + 1; - strategy = await incrementSequence(ctx, strategy); - } - } else { - throw errorWithCode("UNSUPPORTED_OP", "Unsupported element op"); - } - } - } else if (op.entityType === "lineup") { - if (op.kind === "add") { - const lineupPublicId = op.entityPublicId; - const pagePublicId = op.pagePublicId; - if (!lineupPublicId || !pagePublicId || !op.payload) { - throw errorWithCode("MISSING_ADD_LINEUP_ARGS", "Missing add lineup args"); - } - const page = await getPageByPublicId(ctx, pagePublicId); - if (page.strategyId !== strategy._id) { - throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); - } - eventPageId = page._id; - const payload = assertLineupPayload(op.payload); - const now = Date.now(); - const existingLineup = await ctx.db - .query("lineups") - .withIndex("by_publicId", (q) => q.eq("publicId", lineupPublicId)) - .first(); - - if (existingLineup !== null) { - if (existingLineup.strategyId !== strategy._id) { - throw errorWithCode( - "LINEUP_STRATEGY_MISMATCH", - "Lineup strategy mismatch", - ); - } - const patch: Record = {}; - setIfChanged(patch, "pageId", existingLineup.pageId, page._id); - setIfChanged( - patch, - "payloadKind", - existingLineup.payloadKind, - payload.kind, - ); - setIfChanged( - patch, - "payloadVersion", - existingLineup.payloadVersion, - payload.payloadVersion, - ); - setIfChanged(patch, "payload", existingLineup.payload, payload); - setIfChanged( - patch, - "sortIndex", - existingLineup.sortIndex, - op.sortIndex ?? existingLineup.sortIndex, - ); - setIfChanged(patch, "deleted", existingLineup.deleted, false); - - if (hasChanges(patch)) { - await ctx.db.patch(existingLineup._id, { - ...patch, - revision: existingLineup.revision + 1, - updatedAt: now, - }); - appliedRevision = existingLineup.revision + 1; - } else { - markNoop(existingLineup.revision); - } - } else { - await ctx.db.insert("lineups", { - publicId: lineupPublicId, - strategyId: strategy._id, - pageId: page._id, - payloadKind: payload.kind, - payloadVersion: payload.payloadVersion, - payload, - sortIndex: op.sortIndex ?? 0, - revision: 1, - deleted: false, - createdAt: now, - updatedAt: now, - }); - appliedRevision = 1; - } - if (shouldRecordEvent) { - strategy = await incrementSequence(ctx, strategy); - } - } else { - if (!op.entityPublicId) { - throw errorWithCode("MISSING_ENTITY_PUBLIC_ID", "Missing entityPublicId"); - } - const lineup = await getLineupByPublicId(ctx, op.entityPublicId); - if (lineup.strategyId !== strategy._id) { - throw errorWithCode("LINEUP_STRATEGY_MISMATCH", "Lineup strategy mismatch"); - } - eventPageId = lineup.pageId; - - latestRevision = lineup.revision; - latestPayload = lineup.payload; - - if ( - op.expectedRevision !== undefined && - op.expectedRevision !== lineup.revision - ) { - status = "reject"; - reason = "revision_mismatch"; - } else if (op.kind === "delete") { - if (lineup.deleted) { - markNoop(lineup.revision); - } else { - await ctx.db.patch(lineup._id, { - deleted: true, - revision: lineup.revision + 1, - updatedAt: Date.now(), - }); - appliedRevision = lineup.revision + 1; - strategy = await incrementSequence(ctx, strategy); - } - } else if (op.kind === "patch" || op.kind === "move") { - const patch: Record = {}; - if (op.payload !== undefined) { - const payload = assertLineupPayload(op.payload); - setIfChanged(patch, "payload", lineup.payload, payload); - setIfChanged( - patch, - "payloadKind", - lineup.payloadKind, - payload.kind, - ); - setIfChanged( - patch, - "payloadVersion", - lineup.payloadVersion, - payload.payloadVersion, - ); - } - if (op.sortIndex !== undefined) { - setIfChanged( - patch, - "sortIndex", - lineup.sortIndex, - op.sortIndex, - ); - } - if (op.pagePublicId !== undefined) { - const page = await getPageByPublicId(ctx, op.pagePublicId); - if (page.strategyId !== strategy._id) { - throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); - } - setIfChanged(patch, "pageId", lineup.pageId, page._id); - eventPageId = page._id; - } - if (hasChanges(patch)) { - await ctx.db.patch(lineup._id, { - ...patch, - revision: lineup.revision + 1, - updatedAt: Date.now(), - }); - appliedRevision = lineup.revision + 1; - strategy = await incrementSequence(ctx, strategy); - } else { - markNoop(lineup.revision); - } - } else if (op.kind === "reorder") { - const nextSortIndex = op.sortIndex ?? lineup.sortIndex; - if (valuesEqual(lineup.sortIndex, nextSortIndex)) { - markNoop(lineup.revision); - } else { - await ctx.db.patch(lineup._id, { - sortIndex: nextSortIndex, - revision: lineup.revision + 1, - updatedAt: Date.now(), - }); - appliedRevision = lineup.revision + 1; - strategy = await incrementSequence(ctx, strategy); - } - } else { - throw errorWithCode("UNSUPPORTED_OP", "Unsupported lineup op"); - } - } + result = await applyElementOp(ctx, strategy, op); } else { - throw errorWithCode("UNSUPPORTED_OP", "Unsupported entityType"); + result = await applyLineupOp(ctx, strategy, op); } } catch (error) { - if (error instanceof ConvexError) { - status = "reject"; - const code = - typeof error.data?.code === "string" - ? error.data.code - : "INTERNAL_ERROR"; - reason = code.toLowerCase(); - shouldRecordEvent = true; - } else { - throw error; - } - } - - if (shouldRecordEvent) { - await ctx.db.insert("operationEvents", { - strategyId: strategy._id, - pageId: eventPageId, - clientId: args.clientId, - opId: op.opId, - opType: `${op.entityType}.${op.kind}`, - status, - reason, - expectedSequence: op.expectedSequence, - appliedSequence: status === "ack" ? strategy.sequence : undefined, - expectedRevision: op.expectedRevision, - appliedRevision, - createdAt: Date.now(), - }); + if (!(error instanceof ConvexError)) throw error; + const code = + typeof error.data?.code === "string" + ? error.data.code.toLowerCase() + : "internal_error"; + const latest = await getTargetSnapshot(ctx, strategy, op); + result = rejected(code, latest); } + await ctx.db.insert("operationEvents", { + strategyId: strategy._id, + pageId: result.eventPageId, + clientId: args.clientId, + opId: op.opId, + opType: `${op.entityType}.${op.kind}`, + status: result.status, + reason: result.reason, + expectedRevision: op.expectedRevision, + appliedRevision: result.appliedRevision, + createdAt: Date.now(), + }); results.push({ opId: op.opId, - status, - reason: reason ?? null, - appliedSequence: status === "ack" ? strategy.sequence : null, - expectedSequence: op.expectedSequence ?? null, - appliedRevision: appliedRevision ?? null, + status: result.status, + reason: result.reason ?? null, + appliedRevision: result.appliedRevision ?? null, expectedRevision: op.expectedRevision ?? null, - latestSequence: strategy.sequence, - latestRevision: latestRevision ?? null, - latestPayload: latestPayload ?? null, + latestRevision: result.latestRevision ?? null, + latestPayload: result.latestPayload ?? null, }); } - return { - strategyPublicId: strategy.publicId, - sequence: strategy.sequence, - results, - }; + return { strategyPublicId: strategy.publicId, results }; }, }); diff --git a/convex/page.ts b/convex/page.ts new file mode 100644 index 00000000..c292f0d0 --- /dev/null +++ b/convex/page.ts @@ -0,0 +1,79 @@ +import { query } from "./_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "./_generated/dataModel"; +import { assertStrategyRole } from "./lib/auth"; +import { getPageByPublicId, getStrategyByPublicId } from "./lib/entities"; +import { errorWithCode, internalError } from "./lib/errors"; +import { + collectReferencedAssetIds, + getViewerAssetForStrategy, + serializeAssetForViewer, +} from "./lib/imageAssets"; +import { + serializeElement, + serializeLineup, + serializePageContent, + serializePageDescriptor, +} from "./lib/snapshotSerialization"; + +export const getSnapshot = query({ + args: { + strategyPublicId: v.string(), + pagePublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + const page = await getPageByPublicId(ctx, args.pagePublicId); + if (page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + + const [pageContents, elements, lineups] = await Promise.all([ + ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .take(2), + ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(), + ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(), + ]); + if (pageContents.length !== 1) { + throw internalError("Each page must have exactly one page content row."); + } + + const referencedAssetIds = collectReferencedAssetIds(elements, lineups); + const assets = await Promise.all( + [...referencedAssetIds].map((assetPublicId) => + getViewerAssetForStrategy(ctx, strategy._id, assetPublicId), + ), + ); + + return { + page: serializePageDescriptor(strategy.publicId, page), + content: serializePageContent(pageContents[0]!), + elements: elements + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((element) => + serializeElement(strategy.publicId, page.publicId, element), + ), + lineups: lineups + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((lineup) => + serializeLineup(strategy.publicId, page.publicId, lineup), + ), + assets: ( + await Promise.all( + assets + .filter((asset): asset is Doc<"imageAssets"> => asset !== null) + .map((asset) => serializeAssetForViewer(ctx, asset)), + ) + ).sort((left, right) => left.publicId.localeCompare(right.publicId)), + }; + }, +}); diff --git a/convex/pages.ts b/convex/pages.ts index 4e750e5e..4c77c474 100644 --- a/convex/pages.ts +++ b/convex/pages.ts @@ -3,6 +3,7 @@ import { v } from "convex/values"; import { assertStrategyRole } from "./lib/auth"; import { purgeDeletedPageOrphansRef } from "./maintenance"; import { + clampPageIndex, getPageByPublicId, getStrategyByPublicId, sortByNumberField, @@ -13,42 +14,30 @@ import { invalidOpError, notFoundError, errorWithCode, + internalError, } from "./lib/errors"; - -function settingsEqual(left: unknown, right: unknown): boolean { - return JSON.stringify(left ?? null) === JSON.stringify(right ?? null); -} +import { serializePageDescriptor } from "./lib/snapshotSerialization"; +import { valuesEqual } from "./lib/canonicalValues"; export const listForStrategy = query({ - args: { - strategyPublicId: v.string(), - }, + args: { strategyPublicId: v.string() }, handler: async (ctx, args) => { const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "viewer"); - const pages = await ctx.db .query("pages") .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) .collect(); - - return sortByNumberField(pages, "sortIndex").map((page) => ({ - publicId: page.publicId, - strategyPublicId: strategy.publicId, - name: page.name, - sortIndex: page.sortIndex, - isAttack: page.isAttack, - settings: page.settings ?? null, - revision: page.revision, - createdAt: page.createdAt, - updatedAt: page.updatedAt, - })); + return sortByNumberField(pages, "sortIndex").map((page) => + serializePageDescriptor(strategy.publicId, page), + ); }, }); export const add = mutation({ args: { strategyPublicId: v.string(), + expectedRevision: v.number(), pagePublicId: v.string(), name: v.string(), sortIndex: v.number(), @@ -58,61 +47,82 @@ export const add = mutation({ handler: async (ctx, args) => { const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); - - const now = Date.now(); const existingPage = await ctx.db .query("pages") .withIndex("by_publicId", (q) => q.eq("publicId", args.pagePublicId)) .first(); + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + if (existingPage !== null) { if (existingPage.strategyId !== strategy._id) { - throw conflictError(`Page publicId already exists: ${args.pagePublicId}`); + throw conflictError( + `Page publicId already exists: ${args.pagePublicId}`, + ); } - - const settingsChanged = !settingsEqual(existingPage.settings, args.settings); - const hasChanges = - existingPage.name !== args.name || - existingPage.sortIndex !== args.sortIndex || - existingPage.isAttack !== args.isAttack || - settingsChanged; - if (!hasChanges) { - return { ok: true, reused: true }; + const pageContents = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", existingPage._id)) + .take(2); + if (pageContents.length !== 1) { + throw internalError( + "Each page must have exactly one page content row.", + ); } - - await ctx.db.patch(existingPage._id, { - name: args.name, - sortIndex: args.sortIndex, - isAttack: args.isAttack, - settings: args.settings, - revision: existingPage.revision + 1, - updatedAt: now, - }); - - await ctx.db.patch(strategy._id, { - sequence: strategy.sequence + 1, - updatedAt: now, - }); - return { ok: true, reused: true }; + const desiredSortIndex = clampPageIndex( + args.sortIndex, + Math.max(0, pages.length - 1), + ); + const identical = + existingPage.name === args.name && + existingPage.sortIndex === desiredSortIndex && + existingPage.isAttack === args.isAttack && + valuesEqual(pageContents[0]!.settings, args.settings); + if (identical) { + return { ok: true, reused: true, revision: strategy.revision }; + } + throw conflictError(`Page publicId already exists: ${args.pagePublicId}`); + } + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); } - await ctx.db.insert("pages", { + const now = Date.now(); + const orderedPages = sortByNumberField(pages, "sortIndex"); + const desiredSortIndex = clampPageIndex(args.sortIndex, orderedPages.length); + for (let index = 0; index < orderedPages.length; index += 1) { + const page = orderedPages[index]!; + const normalizedIndex = index >= desiredSortIndex ? index + 1 : index; + if (page.sortIndex !== normalizedIndex) { + await ctx.db.patch(page._id, { + sortIndex: normalizedIndex, + revision: page.revision + 1, + updatedAt: now, + }); + } + } + const pageId = await ctx.db.insert("pages", { publicId: args.pagePublicId, strategyId: strategy._id, name: args.name, - sortIndex: args.sortIndex, + sortIndex: desiredSortIndex, isAttack: args.isAttack, - settings: args.settings, revision: 1, createdAt: now, updatedAt: now, }); - - await ctx.db.patch(strategy._id, { - sequence: strategy.sequence + 1, + await ctx.db.insert("pageContents", { + pageId, + settings: args.settings, + revision: 1, + createdAt: now, updatedAt: now, }); - - return { ok: true }; + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { ok: true, revision }; }, }); @@ -121,29 +131,29 @@ export const rename = mutation({ strategyPublicId: v.string(), pagePublicId: v.string(), name: v.string(), + expectedRevision: v.number(), }, handler: async (ctx, args) => { const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); - const page = await getPageByPublicId(ctx, args.pagePublicId); if (page.strategyId !== strategy._id) { throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); } + if (page.name === args.name) { + return { ok: true, reused: true, revision: page.revision }; + } + if (args.expectedRevision !== page.revision) { + throw conflictError("Page revision mismatch"); + } - const now = Date.now(); + const revision = page.revision + 1; await ctx.db.patch(page._id, { name: args.name, - revision: page.revision + 1, - updatedAt: now, - }); - - await ctx.db.patch(strategy._id, { - sequence: strategy.sequence + 1, - updatedAt: now, + revision, + updatedAt: Date.now(), }); - - return { ok: true }; + return { ok: true, revision }; }, }); @@ -151,52 +161,59 @@ export const deletePage = mutation({ args: { strategyPublicId: v.string(), pagePublicId: v.string(), + expectedRevision: v.number(), }, handler: async (ctx, args) => { const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); - const pages = await ctx.db .query("pages") .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) .collect(); - + const page = pages.find( + (candidate) => candidate.publicId === args.pagePublicId, + ); + if (page === undefined) { + return { ok: true, reused: true, revision: strategy.revision }; + } if (pages.length <= 1) { throw invalidOpError("Cannot delete last page"); } - - const page = await getPageByPublicId(ctx, args.pagePublicId); - if (page.strategyId !== strategy._id) { - throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); } + const pageContents = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + for (const pageContent of pageContents) { + await ctx.db.delete(pageContent._id); + } await ctx.db.delete(page._id); - await ctx.scheduler.runAfter(0, purgeDeletedPageOrphansRef, { pageId: page._id, }); const ordered = sortByNumberField( - pages.filter((p) => p._id !== page._id), + pages.filter((candidate) => candidate._id !== page._id), "sortIndex", ); - for (let i = 0; i < ordered.length; i += 1) { - const current = ordered[i]!; - if (current.sortIndex !== i) { + const now = Date.now(); + for (let index = 0; index < ordered.length; index += 1) { + const current = ordered[index]!; + if (current.sortIndex !== index) { await ctx.db.patch(current._id, { - sortIndex: i, + sortIndex: index, revision: current.revision + 1, - updatedAt: Date.now(), + updatedAt: now, }); } } - await ctx.db.patch(strategy._id, { - sequence: strategy.sequence + 1, - updatedAt: Date.now(), - }); - - return { ok: true }; + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { ok: true, revision }; }, }); @@ -204,44 +221,48 @@ export const reorder = mutation({ args: { strategyPublicId: v.string(), orderedPagePublicIds: v.array(v.string()), + expectedRevision: v.number(), }, handler: async (ctx, args) => { const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); - const pages = await ctx.db .query("pages") .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) .collect(); - if (pages.length !== args.orderedPagePublicIds.length) { throw invalidOpError("Page count mismatch"); } + if (new Set(args.orderedPagePublicIds).size !== pages.length) { + throw invalidOpError("Page order must include each page exactly once"); + } + const pageByPublicId = new Map(pages.map((page) => [page.publicId, page])); + const ordered = args.orderedPagePublicIds.map((publicId) => { + const page = pageByPublicId.get(publicId); + if (page === undefined) throw notFoundError("Page", publicId); + return page; + }); + if (ordered.every((page, index) => page.sortIndex === index)) { + return { ok: true, reused: true, revision: strategy.revision }; + } + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); + } - const pageByPublicId = new Map(pages.map((p) => [p.publicId, p])); const now = Date.now(); - - for (let i = 0; i < args.orderedPagePublicIds.length; i += 1) { - const publicId = args.orderedPagePublicIds[i]!; - const page = pageByPublicId.get(publicId); - if (!page) { - throw notFoundError("Page", publicId); - } - if (page.sortIndex !== i) { + for (let index = 0; index < ordered.length; index += 1) { + const page = ordered[index]!; + if (page.sortIndex !== index) { await ctx.db.patch(page._id, { - sortIndex: i, + sortIndex: index, revision: page.revision + 1, updatedAt: now, }); } } - - await ctx.db.patch(strategy._id, { - sequence: strategy.sequence + 1, - updatedAt: now, - }); - - return { ok: true }; + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { ok: true, revision }; }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 47b64f76..2a9fe2e1 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -40,7 +40,7 @@ export default defineSchema({ folderId: v.optional(v.id("folders")), name: v.string(), mapData: v.string(), - sequence: v.number(), + revision: v.number(), themeProfileId: v.optional(v.string()), themeOverridePalette: v.optional(mapThemePaletteValidator), createdAt: v.number(), @@ -55,13 +55,19 @@ export default defineSchema({ name: v.string(), sortIndex: v.number(), isAttack: v.boolean(), - settings: v.optional(strategySettingsValidator), revision: v.number(), createdAt: v.number(), updatedAt: v.number(), }) .index("by_publicId", ["publicId"]) .index("by_strategyId", ["strategyId"]), + pageContents: defineTable({ + pageId: v.id("pages"), + settings: v.optional(strategySettingsValidator), + revision: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + }).index("by_pageId", ["pageId"]), elements: defineTable({ publicId: v.string(), strategyId: v.id("strategies"), @@ -197,8 +203,6 @@ export default defineSchema({ opType: v.string(), status: v.union(v.literal("ack"), v.literal("reject")), reason: v.optional(v.string()), - expectedSequence: v.optional(v.number()), - appliedSequence: v.optional(v.number()), expectedRevision: v.optional(v.number()), appliedRevision: v.optional(v.number()), createdAt: v.number(), diff --git a/convex/snapshot.ts b/convex/snapshot.ts deleted file mode 100644 index 5cb5f29f..00000000 --- a/convex/snapshot.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { query } from "./_generated/server"; -import { v } from "convex/values"; -import type { Doc } from "./_generated/dataModel"; -import { assertStrategyRole } from "./lib/auth"; -import { getStrategyByPublicId, sortByNumberField } from "./lib/entities"; -import { - collectReferencedAssetIds, - getViewerAssetForStrategy, - serializeAssetForViewer, -} from "./lib/imageAssets"; - -export const get = query({ - args: { - strategyPublicId: v.string(), - }, - handler: async (ctx, args) => { - const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); - const { role } = await assertStrategyRole(ctx, strategy, "viewer"); - - const [pages, elements, lineups] = await Promise.all([ - ctx.db - .query("pages") - .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) - .collect(), - ctx.db - .query("elements") - .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) - .collect(), - ctx.db - .query("lineups") - .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) - .collect(), - ]); - const pagePublicIds = new Map( - pages.map((page) => [page._id, page.publicId]), - ); - const visibleElements = elements.filter((element) => - pagePublicIds.has(element.pageId), - ); - const visibleLineups = lineups.filter((lineup) => - pagePublicIds.has(lineup.pageId), - ); - - const referencedAssetIds = collectReferencedAssetIds( - visibleElements, - visibleLineups, - ); - const assets = await Promise.all( - [...referencedAssetIds].map((assetPublicId) => - getViewerAssetForStrategy(ctx, strategy._id, assetPublicId), - ), - ); - - return { - header: { - publicId: strategy.publicId, - name: strategy.name, - mapData: strategy.mapData, - sequence: strategy.sequence, - createdAt: strategy.createdAt, - updatedAt: strategy.updatedAt, - themeProfileId: strategy.themeProfileId ?? null, - themeOverridePalette: strategy.themeOverridePalette ?? null, - role, - }, - pages: sortByNumberField(pages, "sortIndex").map((page) => ({ - publicId: page.publicId, - strategyPublicId: strategy.publicId, - name: page.name, - sortIndex: page.sortIndex, - isAttack: page.isAttack, - settings: page.settings ?? null, - revision: page.revision, - createdAt: page.createdAt, - updatedAt: page.updatedAt, - })), - elements: visibleElements - .sort((a, b) => a.sortIndex - b.sortIndex) - .map((element) => ({ - publicId: element.publicId, - strategyPublicId: strategy.publicId, - pagePublicId: pagePublicIds.get(element.pageId)!, - elementType: element.elementType, - payload: element.payload, - sortIndex: element.sortIndex, - revision: element.revision, - deleted: element.deleted, - createdAt: element.createdAt, - updatedAt: element.updatedAt, - })), - lineups: visibleLineups - .sort((a, b) => a.sortIndex - b.sortIndex) - .map((lineup) => ({ - publicId: lineup.publicId, - strategyPublicId: strategy.publicId, - pagePublicId: pagePublicIds.get(lineup.pageId)!, - payload: lineup.payload, - sortIndex: lineup.sortIndex, - revision: lineup.revision, - deleted: lineup.deleted, - createdAt: lineup.createdAt, - updatedAt: lineup.updatedAt, - })), - assets: await Promise.all( - assets - .filter((asset): asset is Doc<"imageAssets"> => asset !== null) - .map((asset) => serializeAssetForViewer(ctx, asset)), - ), - }; - }, -}); diff --git a/convex/strategies.ts b/convex/strategies.ts index 86f0d6e5..62872513 100644 --- a/convex/strategies.ts +++ b/convex/strategies.ts @@ -37,7 +37,7 @@ type InitialPageInput = { publicId: string; name: string; isAttack: boolean; - settings?: Doc<"pages">["settings"]; + settings?: Doc<"pageContents">["settings"]; }; function createPublicId(): string { @@ -122,7 +122,7 @@ async function summarizeStrategies( publicId: string; name: string; mapData: string; - sequence: number; + revision: number; createdAt: number; updatedAt: number; role: StrategyRole; @@ -167,7 +167,7 @@ async function summarizeStrategies( publicId: strategy.publicId, name: strategy.name, mapData: strategy.mapData, - sequence: strategy.sequence, + revision: strategy.revision, createdAt: strategy.createdAt, updatedAt: strategy.updatedAt, role, @@ -287,12 +287,18 @@ async function insertInitialPage( now: number; }, ) { - await ctx.db.insert("pages", { + const pageId = await ctx.db.insert("pages", { publicId: args.initialPage.publicId, strategyId: args.strategyId, name: args.initialPage.name, sortIndex: 0, isAttack: args.initialPage.isAttack, + revision: 1, + createdAt: args.now, + updatedAt: args.now, + }); + await ctx.db.insert("pageContents", { + pageId, settings: args.initialPage.settings, revision: 1, createdAt: args.now, @@ -345,7 +351,7 @@ async function createStrategyWithInitialPageRecord( folderId, name: args.name, mapData: args.mapData, - sequence: 0, + revision: 0, themeProfileId: args.themeProfileId, themeOverridePalette: args.themeOverridePalette, createdAt: now, @@ -420,7 +426,7 @@ export const getHeader = query({ publicId: strategy.publicId, name: strategy.name, mapData: strategy.mapData, - sequence: strategy.sequence, + revision: strategy.revision, createdAt: strategy.createdAt, updatedAt: strategy.updatedAt, themeProfileId: strategy.themeProfileId ?? null, @@ -476,6 +482,7 @@ export const createWithInitialPage = mutation({ export const update = mutation({ args: { strategyPublicId: v.string(), + expectedRevision: v.number(), name: v.optional(v.string()), mapData: v.optional(v.string()), themeProfileId: v.optional(v.string()), @@ -487,40 +494,65 @@ export const update = mutation({ const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); - const patch: Record = { - updatedAt: Date.now(), - sequence: strategy.sequence + 1, - }; + const patch: Record = {}; - if (args.name !== undefined) patch.name = args.name; - if (args.mapData !== undefined) patch.mapData = args.mapData; + if (args.name !== undefined && args.name !== strategy.name) { + patch.name = args.name; + } + if (args.mapData !== undefined && args.mapData !== strategy.mapData) { + patch.mapData = args.mapData; + } if (args.clearThemeProfileId === true) { - patch.themeProfileId = undefined; - } else if (args.themeProfileId !== undefined) { + if (strategy.themeProfileId !== undefined) { + patch.themeProfileId = undefined; + } + } else if ( + args.themeProfileId !== undefined && + args.themeProfileId !== strategy.themeProfileId + ) { patch.themeProfileId = args.themeProfileId; } if (args.clearThemeOverridePalette === true) { - patch.themeOverridePalette = undefined; + if (strategy.themeOverridePalette !== undefined) { + patch.themeOverridePalette = undefined; + } } else if (args.themeOverridePalette !== undefined) { patch.themeOverridePalette = args.themeOverridePalette; } - await ctx.db.patch(strategy._id, patch); - return { ok: true }; + if (Object.keys(patch).length === 0) { + return { ok: true, reused: true, revision: strategy.revision }; + } + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); + } + + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { + ...patch, + revision, + updatedAt: Date.now(), + }); + return { ok: true, revision }; }, }); export const move = mutation({ args: { strategyPublicId: v.string(), + expectedRevision: v.number(), folderPublicId: v.optional(v.string()), }, handler: async (ctx, args) => { const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "editor"); + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); + } + let folderId; if (args.folderPublicId !== undefined) { const folder = await getFolderByPublicId(ctx, args.folderPublicId); @@ -532,7 +564,7 @@ export const move = mutation({ await ctx.db.patch(strategy._id, { folderId, - sequence: strategy.sequence + 1, + revision: strategy.revision + 1, updatedAt: Date.now(), }); @@ -543,10 +575,14 @@ export const move = mutation({ export const deleteStrategy = mutation({ args: { strategyPublicId: v.string(), + expectedRevision: v.number(), }, handler: async (ctx, args) => { const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); await assertStrategyRole(ctx, strategy, "owner"); + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); + } const pages = await ctx.db .query("pages") @@ -554,6 +590,13 @@ export const deleteStrategy = mutation({ .collect(); for (const page of pages) { + const pageContents = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + for (const pageContent of pageContents) { + await ctx.db.delete(pageContent._id); + } await ctx.db.delete(page._id); await ctx.scheduler.runAfter(0, purgeDeletedPageOrphansRef, { pageId: page._id, diff --git a/convex/strategy.ts b/convex/strategy.ts new file mode 100644 index 00000000..774e175c --- /dev/null +++ b/convex/strategy.ts @@ -0,0 +1,138 @@ +import { query, type QueryCtx } from "./_generated/server"; +import { v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; +import { assertStrategyRole } from "./lib/auth"; +import { getStrategyByPublicId, sortByNumberField } from "./lib/entities"; +import { + collectReferencedAssetIds, + getViewerAssetForStrategy, + serializeAssetForViewer, +} from "./lib/imageAssets"; +import { + serializeElement, + serializeLineup, + serializePageContent, + serializePageDescriptor, + serializeStrategyHeader, +} from "./lib/snapshotSerialization"; +import { internalError } from "./lib/errors"; + +async function getPageContent( + ctx: QueryCtx, + pageId: Id<"pages">, +): Promise> { + const rows = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", pageId)) + .take(2); + if (rows.length !== 1) { + throw internalError("Each page must have exactly one page content row."); + } + return rows[0]!; +} + +export const getShell = query({ + args: { + strategyPublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { role } = await assertStrategyRole(ctx, strategy, "viewer"); + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + + return { + header: serializeStrategyHeader(strategy, role), + pages: sortByNumberField(pages, "sortIndex").map((page) => + serializePageDescriptor(strategy.publicId, page), + ), + }; + }, +}); + +export const getFullSnapshot = query({ + args: { + strategyPublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { role } = await assertStrategyRole(ctx, strategy, "viewer"); + const [pages, elements, lineups] = await Promise.all([ + ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(), + ctx.db + .query("elements") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(), + ctx.db + .query("lineups") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(), + ]); + const orderedPages = sortByNumberField(pages, "sortIndex"); + const pagePublicIds = new Map( + orderedPages.map((page) => [page._id, page.publicId]), + ); + const pageContents = await Promise.all( + orderedPages.map((page) => getPageContent(ctx, page._id)), + ); + const visibleElements = elements.filter((element) => + pagePublicIds.has(element.pageId), + ); + const visibleLineups = lineups.filter((lineup) => + pagePublicIds.has(lineup.pageId), + ); + const referencedAssetIds = collectReferencedAssetIds( + visibleElements, + visibleLineups, + ); + const assets = await Promise.all( + [...referencedAssetIds].map((assetPublicId) => + getViewerAssetForStrategy(ctx, strategy._id, assetPublicId), + ), + ); + + return { + header: serializeStrategyHeader(strategy, role), + pages: orderedPages.map((page, index) => { + const content = serializePageContent(pageContents[index]!); + return { + ...serializePageDescriptor(strategy.publicId, page), + settings: content.settings, + contentRevision: content.revision, + contentCreatedAt: content.createdAt, + contentUpdatedAt: content.updatedAt, + }; + }), + elements: visibleElements + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((element) => + serializeElement( + strategy.publicId, + pagePublicIds.get(element.pageId)!, + element, + ), + ), + lineups: visibleLineups + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((lineup) => + serializeLineup( + strategy.publicId, + pagePublicIds.get(lineup.pageId)!, + lineup, + ), + ), + assets: ( + await Promise.all( + assets + .filter((asset): asset is Doc<"imageAssets"> => asset !== null) + .map((asset) => serializeAssetForViewer(ctx, asset)), + ) + ).sort((left, right) => left.publicId.localeCompare(right.publicId)), + }; + }, +}); diff --git a/convex/syncBoundaries.test.ts b/convex/syncBoundaries.test.ts new file mode 100644 index 00000000..1006a70e --- /dev/null +++ b/convex/syncBoundaries.test.ts @@ -0,0 +1,1223 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import type { DataModel } from "./_generated/dataModel"; +import schema from "./schema"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createStrategyWithInitialPage = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); +const getShell = makeFunctionReference<"query">("strategy:getShell"); +const getPageSnapshot = makeFunctionReference<"query">("page:getSnapshot"); +const getFullSnapshot = makeFunctionReference<"query">( + "strategy:getFullSnapshot", +); +const addPage = makeFunctionReference<"mutation">("pages:add"); +const deletePage = makeFunctionReference<"mutation">("pages:delete"); +const reorderPages = makeFunctionReference<"mutation">("pages:reorder"); + +const identity = { + issuer: "https://sync-boundaries.test", + subject: "owner", + tokenIdentifier: "sync-boundaries|owner", + name: "Sync Owner", +}; +const strategyPublicId = "strategy-sync-boundaries"; +const pageA = "page-a"; +const pageB = "page-b"; +const settingsA = { + agentSize: 48, + abilitySize: 32, + useNeutralTeamColors: false, +}; +const settingsB = { + agentSize: 52, + abilitySize: 36, + useNeutralTeamColors: true, +}; + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; + +function textPayload(text: string) { + return { + kind: "text" as const, + payloadVersion: 1, + data: { text, elementType: "text" }, + }; +} + +function imagePayload(assetPublicId: string) { + return { + kind: "image" as const, + payloadVersion: 1, + data: { id: assetPublicId, elementType: "image" }, + }; +} + +function lineupPayload(assetPublicId: string) { + return { + kind: "lineupGroup" as const, + payloadVersion: 1, + data: { + name: "B lineup", + items: [{ images: [{ id: assetPublicId }] }], + }, + }; +} + +async function createHarness(): Promise<{ + t: RootHarness; + owner: Harness; +}> { + const t = convexTest(schema, modules); + const owner = t.withIdentity(identity); + await owner.mutation(ensureCurrentUser, {}); + return { t, owner }; +} + +async function createBaseStrategy(owner: Harness) { + await owner.mutation(createStrategyWithInitialPage, { + publicId: strategyPublicId, + name: "Sync boundary strategy", + mapData: "ascent", + initialPagePublicId: pageA, + initialPageName: "A active", + initialPageIsAttack: true, + initialPageSettings: settingsA, + }); +} + +async function applyOps( + owner: Harness, + clientId: string, + ops: Array>, +) { + return (await owner.mutation(applyBatch, { + strategyPublicId, + clientId, + clientProtocolVersion: 2, + ops, + })) as { + strategyPublicId: string; + results: Array>; + }; +} + +async function getStrategyRow(t: Harness): Promise> { + return await t.run(async (ctx) => { + const row = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .first(); + if (row === null) throw new Error("Missing strategy test row"); + return row as unknown as Record; + }); +} + +async function deleteOperationEvents(t: Harness, opIds: string[]) { + await t.run(async (ctx) => { + const events = await ctx.db.query("operationEvents").collect(); + for (const event of events) { + if (opIds.includes(event.opId)) { + await ctx.db.delete(event._id); + } + } + }); +} + +async function addPageB(owner: Harness, expectedRevision: number) { + const response = await applyOps(owner, "fixture-pages", [ + { + opId: "add-page-b", + kind: "add", + entityType: "page", + pagePublicId: pageB, + payload: { name: "B inactive", isAttack: false, settings: settingsB }, + sortIndex: 1, + expectedRevision, + }, + ]); + expect(response.results[0]).toMatchObject({ status: "ack" }); +} + +async function seedTwoPageContent(t: Harness, owner: Harness) { + await createBaseStrategy(owner); + await addPageB(owner, 0); + + const strategy = await t.run(async (ctx) => { + return await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .first(); + }); + if (strategy === null) throw new Error("Missing strategy test row"); + const pages = await t.run(async (ctx) => { + return await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + }); + const pageAId = pages.find((page) => page.publicId === pageA)?._id; + const pageBId = pages.find((page) => page.publicId === pageB)?._id; + if (pageAId === undefined || pageBId === undefined) { + throw new Error("Missing test pages"); + } + + await t.run(async (ctx) => { + const now = Date.now(); + const assetA = "asset-a"; + const assetB = "asset-b"; + await ctx.db.insert("imageAssets", { + publicId: assetA, + provider: "r2", + strategyId: strategy._id, + objectKey: "tests/asset-a.png", + uploadStatus: "active", + fileExtension: ".png", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: assetB, + provider: "r2", + strategyId: strategy._id, + objectKey: "tests/asset-b.png", + uploadStatus: "active", + fileExtension: ".png", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("elements", { + publicId: "element-a", + strategyId: strategy._id, + pageId: pageAId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: imagePayload(assetA), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("elements", { + publicId: "element-b", + strategyId: strategy._id, + pageId: pageBId, + elementType: "text", + payloadKind: "text", + payloadVersion: 1, + payload: textPayload("B only"), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("lineups", { + publicId: "lineup-b", + strategyId: strategy._id, + pageId: pageBId, + payloadKind: "lineupGroup", + payloadVersion: 1, + payload: lineupPayload(assetB), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); +} + +beforeAll(() => { + process.env.R2_PUBLIC_BASE_URL = "https://assets.sync-boundaries.test"; +}); + +describe("page-scoped read contract", () => { + test("strategy shell contains metadata and descriptors but no page content", async () => { + const { t, owner } = await createHarness(); + await seedTwoPageContent(t, owner); + + const shell = (await owner.query(getShell, { + strategyPublicId, + })) as Record; + expect(shell).toHaveProperty("header"); + expect(shell).toHaveProperty("pages"); + expect(shell).not.toHaveProperty("elements"); + expect(shell).not.toHaveProperty("lineups"); + expect(shell).not.toHaveProperty("assets"); + for (const page of shell.pages as Array>) { + expect(page).not.toHaveProperty("settings"); + } + }); + + test("one page snapshot excludes records and assets from other pages", async () => { + const { t, owner } = await createHarness(); + await seedTwoPageContent(t, owner); + + const snapshot = (await owner.query(getPageSnapshot, { + strategyPublicId, + pagePublicId: pageA, + })) as { + page: { publicId: string }; + content: { settings: unknown }; + elements: Array<{ publicId: string }>; + lineups: Array<{ publicId: string }>; + assets: Array<{ publicId: string }>; + }; + expect(snapshot.page).toMatchObject({ publicId: pageA }); + expect(snapshot.content).toMatchObject({ settings: settingsA }); + expect(snapshot.elements.map((item) => item.publicId)).toEqual([ + "element-a", + ]); + expect(snapshot.lineups).toEqual([]); + expect(snapshot.assets.map((item) => item.publicId)).toEqual(["asset-a"]); + }); + + test("full snapshot reconstructs every page and referenced asset", async () => { + const { t, owner } = await createHarness(); + await seedTwoPageContent(t, owner); + + const snapshot = (await owner.query(getFullSnapshot, { + strategyPublicId, + })) as { + pages: Array<{ publicId: string; settings: unknown }>; + elements: Array<{ publicId: string }>; + lineups: Array<{ publicId: string }>; + assets: Array<{ publicId: string }>; + }; + expect(snapshot.pages).toMatchObject([ + { publicId: pageA, settings: settingsA }, + { publicId: pageB, settings: settingsB }, + ]); + expect(snapshot.elements.map((item) => item.publicId)).toEqual([ + "element-a", + "element-b", + ]); + expect(snapshot.lineups.map((item) => item.publicId)).toEqual(["lineup-b"]); + expect(snapshot.assets.map((item) => item.publicId).sort()).toEqual([ + "asset-a", + "asset-b", + ]); + }); + + test("page creation always creates one page content row", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await addPageB(owner, 0); + + const counts = await t.run(async (ctx) => { + const db = ctx.db as unknown as { + query(table: string): { collect(): Promise> }; + }; + return { + pages: (await db.query("pages").collect()).length, + pageContents: (await db.query("pageContents").collect()).length, + }; + }); + expect(counts).toEqual({ pages: 2, pageContents: 2 }); + }); +}); + +describe("record-scoped write contract", () => { + test("content operations leave the strategy row unchanged", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "content-write", [ + { + opId: "add-element", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("before"), + sortIndex: 0, + }, + ]); + const before = await getStrategyRow(t); + + const response = await applyOps(owner, "content-write", [ + { + opId: "patch-element", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("after"), + expectedRevision: 1, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "ack", + appliedRevision: 2, + }); + expect(await getStrategyRow(t)).toEqual(before); + }); + + test("page settings update only their page content revision", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + const beforeStrategy = await getStrategyRow(t); + const beforePage = (await owner.query(getPageSnapshot, { + strategyPublicId, + pagePublicId: pageA, + })) as { page: { revision: number }; content: { revision: number } }; + + const response = await applyOps(owner, "page-content-write", [ + { + opId: "patch-page-content", + kind: "patch", + entityType: "pageContent", + entityPublicId: pageA, + payload: { settings: settingsB }, + expectedRevision: beforePage.content.revision, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "ack", + appliedRevision: beforePage.content.revision + 1, + }); + const afterPage = (await owner.query(getPageSnapshot, { + strategyPublicId, + pagePublicId: pageA, + })) as { + page: { revision: number }; + content: { revision: number; settings: unknown }; + }; + expect(afterPage.page.revision).toBe(beforePage.page.revision); + expect(afterPage.content).toMatchObject({ + revision: beforePage.content.revision + 1, + settings: settingsB, + }); + expect(await getStrategyRow(t)).toEqual(beforeStrategy); + }); + + test("different entities and pages update from the same strategy revision", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await addPageB(owner, 0); + await applyOps(owner, "parallel-adds", [ + { + opId: "add-a", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("A"), + }, + { + opId: "add-b", + kind: "add", + entityType: "element", + entityPublicId: "element-b", + pagePublicId: pageB, + payload: textPayload("B"), + }, + ]); + + const response = await applyOps(owner, "parallel-patches", [ + { + opId: "patch-a", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("A2"), + expectedRevision: 1, + }, + { + opId: "patch-b", + kind: "patch", + entityType: "element", + entityPublicId: "element-b", + payload: textPayload("B2"), + expectedRevision: 1, + }, + ]); + expect(response.results.map((result) => result.status)).toEqual([ + "ack", + "ack", + ]); + }); + + test("one batch commits accepted ops and visibly rejects stale ops", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "same-entity", [ + { + opId: "add", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("base"), + }, + ]); + + const response = await applyOps(owner, "same-entity", [ + { + opId: "first", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("first"), + expectedRevision: 1, + }, + { + opId: "second", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("second"), + expectedRevision: 1, + }, + ]); + expect(response.results).toMatchObject([ + { status: "ack", appliedRevision: 2 }, + { + status: "reject", + reason: "revision_mismatch", + latestRevision: 2, + }, + ]); + const snapshot = (await owner.query(getPageSnapshot, { + strategyPublicId, + pagePublicId: pageA, + })) as { + elements: Array<{ + publicId: string; + revision: number; + payload: { data: { text: string } }; + }>; + }; + expect(snapshot.elements).toMatchObject([ + { + publicId: "element-a", + revision: 2, + payload: { data: { text: "first" } }, + }, + ]); + }); + + test("page membership changes use the strategy revision", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + const strategy = await getStrategyRow(t); + expect(strategy.revision).toBe(0); + + const accepted = await applyOps(owner, "page-membership", [ + { + opId: "add-page-b", + kind: "add", + entityType: "page", + pagePublicId: pageB, + payload: { name: "B", isAttack: false, settings: settingsB }, + sortIndex: 1, + expectedRevision: 0, + }, + ]); + expect(accepted.results[0]).toMatchObject({ + status: "ack", + appliedRevision: 1, + }); + + const rejected = await applyOps(owner, "page-membership", [ + { + opId: "add-stale-page", + kind: "add", + entityType: "page", + pagePublicId: "page-stale", + payload: { name: "stale", isAttack: true }, + sortIndex: 2, + expectedRevision: 0, + }, + ]); + expect(rejected.results[0]).toMatchObject({ + status: "reject", + reason: "revision_mismatch", + latestRevision: 1, + }); + }); + + test("page reorder uses the strategy revision", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await addPageB(owner, 0); + + const accepted = await applyOps(owner, "page-reorder", [ + { + opId: "move-page-b", + kind: "reorder", + entityType: "page", + entityPublicId: pageB, + sortIndex: 0, + expectedRevision: 1, + }, + ]); + expect(accepted.results[0]).toMatchObject({ + status: "ack", + appliedRevision: 2, + }); + const shell = (await owner.query(getShell, { + strategyPublicId, + })) as { + pages: Array<{ publicId: string; sortIndex: number; revision: number }>; + }; + expect(shell.pages).toMatchObject([ + { publicId: pageB, sortIndex: 0, revision: 2 }, + { publicId: pageA, sortIndex: 1, revision: 2 }, + ]); + + const rejected = await applyOps(owner, "page-reorder", [ + { + opId: "stale-page-a", + kind: "reorder", + entityType: "page", + entityPublicId: pageA, + sortIndex: 0, + expectedRevision: 1, + }, + ]); + expect(rejected.results[0]).toMatchObject({ + status: "reject", + reason: "revision_mismatch", + latestRevision: 2, + }); + }); + + test("page descriptor operations all apply through the durable batch protocol", async () => { + vi.useFakeTimers(); + try { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + + const renamed = await applyOps(owner, "page-descriptors", [ + { + opId: "rename-page-a", + kind: "patch", + entityType: "page", + entityPublicId: pageA, + payload: { name: "A renamed" }, + expectedRevision: 1, + }, + ]); + expect(renamed.results[0]).toMatchObject({ + status: "ack", + appliedRevision: 2, + }); + + const added = await applyOps(owner, "page-descriptors", [ + { + opId: "add-page-b", + kind: "add", + entityType: "page", + entityPublicId: pageB, + payload: { name: "B", isAttack: false, settings: settingsB }, + sortIndex: 1, + expectedRevision: 0, + }, + ]); + expect(added.results[0]).toMatchObject({ + status: "ack", + appliedRevision: 1, + }); + + const reordered = await applyOps(owner, "page-descriptors", [ + { + opId: "reorder-page-b", + kind: "reorder", + entityType: "page", + entityPublicId: pageB, + sortIndex: 0, + expectedRevision: 1, + }, + ]); + expect(reordered.results[0]).toMatchObject({ + status: "ack", + appliedRevision: 2, + }); + + const deleted = await applyOps(owner, "page-descriptors", [ + { + opId: "delete-page-b", + kind: "delete", + entityType: "page", + entityPublicId: pageB, + expectedRevision: 2, + }, + ]); + expect(deleted.results[0]).toMatchObject({ + status: "ack", + appliedRevision: 3, + }); + + const shell = (await owner.query(getShell, { + strategyPublicId, + })) as { + header: { revision: number }; + pages: Array<{ publicId: string; name: string; sortIndex: number }>; + }; + expect(shell.header.revision).toBe(3); + expect(shell.pages).toMatchObject([ + { publicId: pageA, name: "A renamed", sortIndex: 0 }, + ]); + await t.finishAllScheduledFunctions(vi.runAllTimers); + } finally { + vi.useRealTimers(); + } + }); + + test("page add at an occupied position shifts siblings", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + + const response = await applyOps(owner, "page-insert", [ + { + opId: "insert-page-b-first", + kind: "add", + entityType: "page", + pagePublicId: pageB, + payload: { name: "B inactive", isAttack: false, settings: settingsB }, + sortIndex: 0, + expectedRevision: 0, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "ack", + appliedRevision: 1, + }); + + const shell = (await owner.query(getShell, { + strategyPublicId, + })) as { + pages: Array<{ publicId: string; sortIndex: number; revision: number }>; + }; + expect(shell.pages).toMatchObject([ + { publicId: pageB, sortIndex: 0, revision: 1 }, + { publicId: pageA, sortIndex: 1, revision: 2 }, + ]); + }); + + test("soft-deleted elements and lineups can be restored with their ids", async () => { + const elementId = "restorable-element"; + const lineupId = "restorable-lineup"; + const restoredText = "restored"; + const restoredAsset = "restored-asset"; + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "undo-restore", [ + { + opId: "add-element", + kind: "add", + entityType: "element", + entityPublicId: elementId, + pagePublicId: pageA, + payload: textPayload("before delete"), + }, + { + opId: "add-lineup", + kind: "add", + entityType: "lineup", + entityPublicId: lineupId, + pagePublicId: pageA, + payload: lineupPayload("before-delete-asset"), + }, + ]); + await applyOps(owner, "undo-restore", [ + { + opId: "delete-element", + kind: "delete", + entityType: "element", + entityPublicId: elementId, + expectedRevision: 1, + }, + { + opId: "delete-lineup", + kind: "delete", + entityType: "lineup", + entityPublicId: lineupId, + expectedRevision: 1, + }, + ]); + + const missingRevision = await applyOps(owner, "undo-restore-missing", [ + { + opId: "restore-element-missing", + kind: "add", + entityType: "element", + entityPublicId: elementId, + pagePublicId: pageA, + payload: textPayload(restoredText), + }, + { + opId: "restore-lineup-missing", + kind: "add", + entityType: "lineup", + entityPublicId: lineupId, + pagePublicId: pageA, + payload: lineupPayload(restoredAsset), + }, + ]); + expect(missingRevision.results).toMatchObject([ + { + status: "reject", + reason: "missing_expected_revision", + latestRevision: 2, + }, + { + status: "reject", + reason: "missing_expected_revision", + latestRevision: 2, + }, + ]); + + const staleRevision = await applyOps(owner, "undo-restore-stale", [ + { + opId: "restore-element-stale", + kind: "add", + entityType: "element", + entityPublicId: elementId, + pagePublicId: pageA, + payload: textPayload(restoredText), + expectedRevision: 1, + }, + { + opId: "restore-lineup-stale", + kind: "add", + entityType: "lineup", + entityPublicId: lineupId, + pagePublicId: pageA, + payload: lineupPayload(restoredAsset), + expectedRevision: 1, + }, + ]); + expect(staleRevision.results).toMatchObject([ + { status: "reject", reason: "revision_mismatch", latestRevision: 2 }, + { status: "reject", reason: "revision_mismatch", latestRevision: 2 }, + ]); + + const misclassifiedPatch = await applyOps(owner, "undo-restore-patch", [ + { + opId: "restore-element-patch", + kind: "patch", + entityType: "element", + entityPublicId: elementId, + pagePublicId: pageA, + payload: textPayload("before delete"), + sortIndex: 0, + expectedRevision: 2, + }, + { + opId: "restore-lineup-patch", + kind: "patch", + entityType: "lineup", + entityPublicId: lineupId, + pagePublicId: pageA, + payload: lineupPayload("before-delete-asset"), + sortIndex: 0, + expectedRevision: 2, + }, + ]); + expect(misclassifiedPatch.results).toMatchObject([ + { status: "ack", reason: "noop", appliedRevision: 2 }, + { status: "ack", reason: "noop", appliedRevision: 2 }, + ]); + const stillDeleted = (await owner.query(getPageSnapshot, { + strategyPublicId, + pagePublicId: pageA, + })) as { + elements: Array<{ publicId: string; deleted: boolean }>; + lineups: Array<{ publicId: string; deleted: boolean }>; + }; + expect(stillDeleted.elements).toMatchObject([ + { publicId: elementId, deleted: true }, + ]); + expect(stillDeleted.lineups).toMatchObject([ + { publicId: lineupId, deleted: true }, + ]); + + const restored = await applyOps(owner, "undo-restore-current", [ + { + opId: "restore-element-current", + kind: "add", + entityType: "element", + entityPublicId: elementId, + pagePublicId: pageA, + payload: textPayload(restoredText), + expectedRevision: 2, + }, + { + opId: "restore-lineup-current", + kind: "add", + entityType: "lineup", + entityPublicId: lineupId, + pagePublicId: pageA, + payload: lineupPayload(restoredAsset), + expectedRevision: 2, + }, + ]); + expect(restored.results).toMatchObject([ + { status: "ack", appliedRevision: 3 }, + { status: "ack", appliedRevision: 3 }, + ]); + + const snapshot = (await owner.query(getPageSnapshot, { + strategyPublicId, + pagePublicId: pageA, + })) as { + elements: Array<{ + publicId: string; + revision: number; + deleted: boolean; + payload: { data: { text: string } }; + }>; + lineups: Array<{ + publicId: string; + revision: number; + deleted: boolean; + payload: { data: { items: Array<{ images: Array<{ id: string }> }> } }; + }>; + }; + expect(snapshot.elements).toMatchObject([ + { + publicId: elementId, + revision: 3, + deleted: false, + payload: { data: { text: restoredText } }, + }, + ]); + expect(snapshot.lineups).toMatchObject([ + { + publicId: lineupId, + revision: 3, + deleted: false, + payload: { + data: { items: [{ images: [{ id: restoredAsset }] }] }, + }, + }, + ]); + }); + + test("direct page delete replay is idempotent after one page remains", async () => { + vi.useFakeTimers(); + try { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + const added = (await owner.mutation(addPage, { + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "B inactive", + sortIndex: 1, + isAttack: false, + settings: settingsB, + })) as { revision: number }; + expect(added.revision).toBe(1); + + const deleted = (await owner.mutation(deletePage, { + strategyPublicId, + pagePublicId: pageB, + expectedRevision: 1, + })) as { revision: number; reused?: boolean }; + expect(deleted).toMatchObject({ revision: 2 }); + + const replayed = (await owner.mutation(deletePage, { + strategyPublicId, + pagePublicId: pageB, + expectedRevision: 1, + })) as { revision: number; reused?: boolean }; + expect(replayed).toMatchObject({ revision: 2, reused: true }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + } finally { + vi.useRealTimers(); + } + }); + + test("direct page add replay ignores settings key order", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await owner.mutation(addPage, { + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "B inactive", + sortIndex: 1, + isAttack: false, + settings: settingsB, + }); + + const replayed = (await owner.mutation(addPage, { + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "B inactive", + sortIndex: 1, + isAttack: false, + settings: { + useNeutralTeamColors: true, + abilitySize: 36, + agentSize: 52, + }, + })) as { revision: number; reused?: boolean }; + expect(replayed).toMatchObject({ revision: 1, reused: true }); + }); + + test("direct page add normalizes occupied and out-of-range positions", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + + await owner.mutation(addPage, { + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "B inactive", + sortIndex: 0, + isAttack: false, + settings: settingsB, + }); + await owner.mutation(addPage, { + strategyPublicId, + expectedRevision: 1, + pagePublicId: "page-c", + name: "C", + sortIndex: 99, + isAttack: true, + }); + + const shell = (await owner.query(getShell, { + strategyPublicId, + })) as { + pages: Array<{ publicId: string; sortIndex: number; revision: number }>; + }; + expect(shell.pages).toMatchObject([ + { publicId: pageB, sortIndex: 0, revision: 1 }, + { publicId: pageA, sortIndex: 1, revision: 2 }, + { publicId: "page-c", sortIndex: 2, revision: 1 }, + ]); + + const replayed = (await owner.mutation(addPage, { + strategyPublicId, + expectedRevision: 1, + pagePublicId: "page-c", + name: "C", + sortIndex: 99, + isAttack: true, + })) as { revision: number; reused?: boolean }; + expect(replayed).toMatchObject({ revision: 2, reused: true }); + }); + + test("direct page reorder rejects duplicate page ids", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await owner.mutation(addPage, { + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "B inactive", + sortIndex: 1, + isAttack: false, + settings: settingsB, + }); + + await expect( + owner.mutation(reorderPages, { + strategyPublicId, + orderedPagePublicIds: [pageA, pageA], + expectedRevision: 1, + }), + ).rejects.toThrow("Page order must include each page exactly once"); + + const shell = (await owner.query(getShell, { + strategyPublicId, + })) as { + header: { revision: number }; + pages: Array<{ publicId: string; sortIndex: number; revision: number }>; + }; + expect(shell.header.revision).toBe(1); + expect(shell.pages).toMatchObject([ + { publicId: pageA, sortIndex: 0, revision: 1 }, + { publicId: pageB, sortIndex: 1, revision: 1 }, + ]); + }); +}); + +describe("replay safety after operation event expiry", () => { + test("identical add acknowledges and different add rejects", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + const original = { + opId: "replayed-add", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("original"), + sortIndex: 0, + }; + await applyOps(owner, "replay-add", [original]); + await deleteOperationEvents(t, ["replayed-add"]); + + const identical = await applyOps(owner, "replay-add", [original]); + expect(identical.results[0]).toMatchObject({ + status: "ack", + reason: "noop", + }); + + const different = await applyOps(owner, "replay-add-different", [ + { ...original, opId: "different-add", payload: textPayload("different") }, + ]); + expect(different.results[0]).toMatchObject({ + status: "reject", + reason: "already_exists", + }); + }); + + test("matching stale patch acknowledges before revision rejection", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "replay-patch", [ + { + opId: "add", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("base"), + }, + { + opId: "patch", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("desired"), + expectedRevision: 1, + }, + ]); + await deleteOperationEvents(t, ["patch"]); + + const response = await applyOps(owner, "replay-patch", [ + { + opId: "patch", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("desired"), + expectedRevision: 1, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "ack", + reason: "noop", + appliedRevision: 2, + }); + }); + + test("different stale patch rejects with the current entity", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "replay-patch-different", [ + { + opId: "add", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("base"), + }, + { + opId: "advance", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("newer"), + expectedRevision: 1, + }, + ]); + const response = await applyOps(owner, "replay-patch-different", [ + { + opId: "stale", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("different"), + expectedRevision: 1, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "reject", + reason: "revision_mismatch", + latestRevision: 2, + latestPayload: textPayload("newer"), + }); + }); + + test("delete against a missing row acknowledges as a no-op", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + const response = await applyOps(owner, "replay-delete", [ + { + opId: "missing-delete", + kind: "delete", + entityType: "element", + entityPublicId: "missing-element", + expectedRevision: 4, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "ack", + reason: "noop", + }); + }); + + test("reorder already at the desired position acknowledges before revision rejection", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "replay-reorder", [ + { + opId: "add", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("base"), + sortIndex: 0, + }, + { + opId: "advance", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("newer"), + expectedRevision: 1, + }, + ]); + await deleteOperationEvents(t, ["advance"]); + + const response = await applyOps(owner, "replay-reorder", [ + { + opId: "reorder", + kind: "reorder", + entityType: "element", + entityPublicId: "element-a", + sortIndex: 0, + expectedRevision: 1, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "ack", + reason: "noop", + appliedRevision: 2, + }); + }); +}); diff --git a/convex/test.setup.ts b/convex/test.setup.ts new file mode 100644 index 00000000..c4a3523e --- /dev/null +++ b/convex/test.setup.ts @@ -0,0 +1,3 @@ +/// + +export const modules = import.meta.glob("./**/!(*.*.*)*.*s"); diff --git a/docs/cloud_online_release_gaps.md b/docs/cloud_online_release_gaps.md index 305ab249..e37599b6 100644 --- a/docs/cloud_online_release_gaps.md +++ b/docs/cloud_online_release_gaps.md @@ -11,17 +11,17 @@ being released. Do not invite outside testers yet. The local automated baseline is healthy: the Flutter suite passes, Convex -TypeScript passes, and the web release build completes. The leading blocker is -the actual online loop. During a browser smoke test, an existing signed-in -session connected to Convex, then the server rejected a client message with: - -```text -Received Invalid JSON on websocket: missing field `baseVersion` -``` - -The client then reconnected and hit the same fatal error again. Until that is -fixed and the two-client path below passes, the product can render online UI -without proving that it can safely keep a library online. +TypeScript passes, and the web release build completes. The web client's +authentication protocol mismatch is fixed: a production web build completed a +real email/password sign-in, authenticated Convex read and write, a second-page +edit that reached **Synced**, and a reload/readback cycle without a fatal +protocol error or reconnect loop. The temporary strategy was deleted after the +proof. + +The remaining blocker is the complete two-client and failure-recovery path +below. One healthy authenticated client proves the transport boundary, but it +does not yet prove sharing, access control, conflicts, offline recovery, media, +or round-trip fidelity. ## Release Rule @@ -34,9 +34,12 @@ support channel. P2 items can follow the invite-only beta. ## P0: Protect the Library -- [ ] The Convex client and deployed server complete authentication without a +- [x] The Convex client and deployed server complete authentication without a fatal protocol error or reconnect loop. - - Evidence: pending; `baseVersion` mismatch reproduced on 2026-08-25. + - Evidence: production web build verified on 2026-08-25 with real sign-in, + authenticated strategy create, second-page edit to **Synced**, reload, + fresh sign-in, server readback of both pages, and test-data cleanup. No new + protocol error or reconnect loop appeared in the post-fix console. - [ ] A local-mode library created on the current public build opens unchanged after installing the beta. - Evidence: pending; record the public version, beta commit, and fixture. diff --git a/docs/cloud_sync_refactor/convex_sync_refactor_plan.md b/docs/cloud_sync_refactor/convex_sync_refactor_plan.md index 8f9e74c7..5497ebb4 100644 --- a/docs/cloud_sync_refactor/convex_sync_refactor_plan.md +++ b/docs/cloud_sync_refactor/convex_sync_refactor_plan.md @@ -1,4 +1,9 @@ -# Convex Cloud Sync Refactor Plan +# Convex cloud sync refactor plan + +Status: historical first-pass plan. Its strategy-level subscription work is +already present in this branch. Do not continue its sequence-preserving design. +For the current server boundary and durable outbox work, follow +[`server_side_sync_boundaries_handoff.md`](server_side_sync_boundaries_handoff.md). ## Summary @@ -56,7 +61,7 @@ Add model helpers: ### 1. Create The Workflow Doc - [x] Create `docs/cloud_sync_refactor/convex_sync_refactor_plan.md` and save this plan there. -- [x] Use this file as the canonical implementation checklist for the workflow. +- [x] Preserve this file as the historical checklist for the first query refactor. ### 2. Add Strategy-Level Element And Lineup Queries @@ -141,9 +146,10 @@ Follow-up design: - Maintenance points: strategy create/update/move/delete, page add/patch/delete/reorder, share/collaborator changes - Trigger condition: implement only if optimized indexed library queries still show high read bytes or subscription churn -### 10. Step Five Follow-Up Hint +### 10. Superseded follow-up -`strategy.sequence` and `updatedAt` are currently patched after accepted ops and act as the remote change clock. Splitting high-churn sync metadata away from stable strategy metadata could reduce header/library invalidations, but it must be planned separately because `StrategyPageSessionNotifier` depends on `header.sequence` for rehydration. Do not change this in the current refactor. +This plan deferred the `strategy.sequence` hot-write. The current handoff linked +at the top now owns that change and replaces sequence-gated hydration. ## Tests And Verification @@ -199,6 +205,6 @@ Expected `fvm flutter analyze` result: no errors; pre-existing warnings/infos ma - Scope is data plumbing only. - Target documentation location is `docs/cloud_sync_refactor/convex_sync_refactor_plan.md`. - Keep current UI exactly as-is. -- Keep `strategy.sequence` as the remote change clock for this refactor. +- Historical choice: this pass kept `strategy.sequence` as the remote change clock. - Do not implement digest tables until after indexed query cleanup is measured. - Preserve current backend function names where possible; only add new functions for better Convex query shapes. diff --git a/docs/cloud_sync_refactor/server_side_sync_boundaries_blueprint.html b/docs/cloud_sync_refactor/server_side_sync_boundaries_blueprint.html new file mode 100644 index 00000000..6ef944fa --- /dev/null +++ b/docs/cloud_sync_refactor/server_side_sync_boundaries_blueprint.html @@ -0,0 +1,929 @@ + + + + + + + + + Icarus server-side sync boundaries + + + + + + + +
+
+
+ + RFC + +
+
+
+ + + +
+ + +
+
+

Reactive boundary · Proposed · High confidence

+

The server should synchronize one active page, not rebuild the whole strategy

+

Icarus currently has one live query that reads five logical record groups: the strategy, every page, every element, every lineup, and every referenced asset. Convex treats that query as one reactive dependency graph, so an edit on any page can rerun and resend the whole strategy snapshot. The proposed model uses two live queries, one stable strategy shell and one active-page snapshot. Entity revisions keep conflict checks precise, while full-strategy reads remain available for export and recovery without staying subscribed.

+ +
+

The decision

+ +
+
5 groupscurrent live read set
+
2 queriesproposed editor subscriptions
+
1 pagenormal content invalidation
+
0 logsnew delta logs required
+
+ +

Recommended decision: keep Convex and the existing active-page client overlay. Split the server read model into a strategy shell and an active-page snapshot, then stop advancing the strategy row for ordinary page content edits.

+ +

This is not a database rewrite. Most current tables, payloads, indexes, operation IDs, and entity revisions remain useful. The main change is deciding which records each subscribed query may read and which parent records each mutation may write.

+ +

Confidence high The repository proves the current dependency graph. Convex documents that subscribed queries rerun when data they depend on changes. Not measured This document does not claim a byte, latency, or billing reduction because no production workload exists yet.

+
+ +
+

Why a server boundary matters in Convex

+ +

In a conventional request API, an endpoint usually runs once and returns. A Convex query can stay subscribed. Convex records what the query read, then reruns it when relevant database data changes. That makes the query's read set the real synchronization boundary.

+ +

Splitting Flutter providers does not narrow server work if they still subscribe to one broad query. Normalizing data into separate tables also does not help if one query reads every table. The boundary becomes narrow only when the query arguments and indexes let it read a narrow set of records.

+ + +

Sources: convex/snapshot.ts, convex/ops.ts, and Convex reactive query overview. Diagram shows code paths, not measured traffic.

+ +

What happens when one agent moves

+
    +
  1. The active-page overlay creates an element.patch with that element's expected revision.
  2. +
  3. ops:applyBatch patches the element and then increments strategies.sequence and strategies.updatedAt.
  4. +
  5. snapshot:get already depends on that element. It also depends on the parent strategy row and every other page child it collected.
  6. +
  7. Convex reruns snapshot:get. The function reads and serializes every page, element, lineup, and referenced asset again.
  8. +
  9. Flutter receives a new RemoteStrategySnapshot. The active-page overlay protects pending local intent while the snapshot replaces the remote base.
  10. +
+ +

The overlay solves correctness on the client. It cannot shrink the server query that produced the remote base. That is why the existing active-page work and the missing server boundary are separate problems.

+
+ +
+

The proposed server boundaries

+ +

An open editor needs two changing views. The first is the strategy shell: metadata, access role, and the ordered page list. The second is the active page: settings, elements, lineups, and only the assets those records reference.

+ + +

Proposed design derived from existing by_pageId indexes in convex/schema.ts. No schema or query in this diagram has been implemented yet.

+ +
+ + + + + + + + +
Server readLifetimeMay readMust not readWhy it changes
strategy:getShellSubscribed while editor is openStrategy metadata, role, page descriptorsElements, lineups, page content settings, assetsRename, theme, page add/delete/reorder, role change
page:getSnapshotSubscribed for active page onlyOne page's content, entities, referenced assetsOther pages' entities or assetsEdit on active page, asset status change, page deletion
strategy:getFullSnapshotOne-shotEverything required for round-tripNothing required for export may be omittedExplicit export, import verification, recovery
strategies:listForFolderSubscribed while library is visibleSmall summaries and access dataCanvas entities and media payloadsLibrary metadata or membership change
+
+

Source: proposed query contract. The full snapshot remains deliberately broad, but no editor keeps it subscribed.

+
+ +
+

The data model that makes the boundary real

+ +

The key split is inside today's pages document. A shell query needs page descriptors, but it does not need mutable canvas settings. Convex tracks documents read by a query. Mapping only a few fields in TypeScript does not turn a page document into two dependencies. Moving page settings into a one-row pageContents table gives the shell and page snapshot separate records to depend on.

+ + +

Source: proposed model over the current schema in convex/schema.ts. Existing entity, asset, and event rows stay recognizable.

+ +
+ + + + + + + + + + + + +
TableKeepChangeRevision meaning
strategiesIdentity, ownership, folder, name, map, themeReplace global content sequence with metadata or structure revisionOnly strategy metadata and page structure changed
pagesID, strategy ID, name, order, sideMove canvas settings outThis page descriptor changed
pageContentsNew one-to-one rowOwn page settings independentlySettings changed
elementsCurrent payload, tombstone, revision, by_pageIdNo structural change requiredThis element changed
lineupsCurrent payload, tombstone, revision, by_pageIdNo structural change requiredThis lineup changed
imageAssetsCurrent ownership and upload fieldsFetch by referenced ID for the active pageAsset status remains its own state
operationEventsIdempotency and audit fieldsGlobal expected sequence becomes entity or metadata revisionThe acknowledged entity version
strategyActivityNothing yetOptional small library-only rowLast content activity, if product needs it
+
+

Source: proposed schema responsibilities. The server is still pre-release clay, so this can replace the deployed shape without migrating public cloud data.

+ +

Why the first version should not add a page sequence

+

A page-level sequence looks like the natural replacement for the strategy sequence, but the reactive query already watches the active page's rows. Element and lineup revisions already detect conflicting edits to the same entity. Writing one page sequence after every drag would create a new hot row for everyone editing that page.

+ +

The first version should use per-entity revisions and Convex query reactivity. Add a page sequence later only if a concrete recovery case cannot be expressed with query updates, operation acknowledgements, and entity revisions.

+
+ +
+

The mutation contract controls invalidation

+ +

The query split works only if mutations respect it. A normal content edit must stop patching the strategy row. Otherwise every active-page mutation still wakes the shell and any library query that reads the strategy.

+ +
+ + + + + + + + + + + +
OperationRows writtenConflict checkShellPage APage B
Move element on AElement A, eventElement revisionStableRerunsStable
Edit lineup on BLineup B, eventLineup revisionStableStableReruns
Change settings on APageContents A, eventSettings revisionStableRerunsStable
Move element A to BElement changes pageId, eventElement revisionStableRerunsReruns
Rename page APage descriptor A, eventPage revisionRerunsRerunsStable
Reorder pagesPage descriptors, strategy metadata revisionStructure revisionRerunsContent stableContent stable
Rename strategyStrategy, eventMetadata revisionRerunsAuth row read may rerunAuth row read may rerun
+
+

Source: proposed write matrix. "Stable" means the write does not intersect that query's intended database read set.

+ +

Conflict rules become easier to explain

+
    +
  • Two users edit different elements. Both operations land because neither depends on a global content sequence.
  • +
  • Two users edit the same element revision. One lands and the other receives a revision mismatch with the current payload.
  • +
  • Two users edit different pages. Each active-page subscription receives only its page's result.
  • +
  • A page moves or disappears while someone edits it. The shell update changes page membership, and the client flushes or surfaces the pending operation before leaving that page.
  • +
+ +

Convex still provides atomic mutations and retries internal optimistic concurrency conflicts. Icarus entity revisions solve a different problem: they detect user-level edits based on stale content and let the app explain the conflict instead of silently choosing a winner.

+

Source: Convex OCC and atomicity and current expected revision handling in convex/ops.ts.

+
+ +
+

The client can reuse the work that already exists

+ +

The active-page projection is not throwaway code. It already groups intent by page and entity, overlays queued and in-flight changes over the remote base, and compares entity revisions. The server split should feed that controller a smaller remote base.

+ + +

Source: existing page-scoped entity keys and overlay logic in active_page_live_sync_provider.dart and strategy_op_queue_provider.dart.

+ +

The durable outbox is a client boundary, but it completes the server design. A mutation acknowledgement proves the op landed. A query update supplies the new remote base. The outbox must preserve an unacknowledged op until one of those outcomes is visible to the user.

+
+ +
+

The smallest implementation cut

+ +
    +
  1. Add pageContents and reshape the empty Convex deployment. Keep current payload validators and generated client models aligned.
  2. +
  3. Add strategy:getShell, page:getSnapshot, and a one-shot strategy:getFullSnapshot. The page query must use by_pageId for elements and lineups.
  4. +
  5. Replace watchSnapshot with one shell subscription and one active-page subscription. Feed both into the existing projected-page logic.
  6. +
  7. Stop calling incrementSequence for element, lineup, and page-content operations. Keep metadata and structure revisions where stale writes need detection.
  8. +
  9. Persist the operation queue before calling the beta reliable. Remove the path that discards an op after eight failed attempts.
  10. +
  11. Measure query rows, bytes, reruns, and mutation conflicts. Add a library activity table only if users need content-based recency and the measurements support it.
  12. +
+ +
+ + + + + + + + + + +
ProofSetupExpected result
Cross-page isolationSubscribe device A to Page A, edit Page B from device BPage A query does not update
Same-page realtimeBoth devices open Page A, move different elementsBoth operations land and both clients converge
Same-entity conflictBoth devices patch one element from the same revisionOne reject is surfaced, no silent overwrite
Page switchEdit offline on A, switch to B, restart appA's pending op remains visible and retryable
Round-tripExport full strategy, import into a clean local libraryPages, settings, entities, lineups, and media match
Query budgetRecord Convex metrics for one element dragRows and bytes scale with active page, not strategy page count
+
+

Source: proposed acceptance tests. Performance rows need live Convex metrics before a numeric target is set.

+
+ +
+

What this design refuses to add

+
    +
  • No tunneled peer-to-peer transport. Convex remains the durable library and collaboration authority.
  • +
  • No custom delta log for normal synchronization. Convex reactive queries and entity revisions already provide the needed mechanism.
  • +
  • No subscription per inactive page. The editor keeps one page content subscription and changes its argument on page switch.
  • +
  • No global content sequence. Different entities and pages should not conflict merely because their edits happened near each other.
  • +
  • No claim that the refactor lowers cost by a specific amount until live query metrics exist.
  • +
+
+ +
+

Evidence and assumptions

+
+ Current dependency evidence · repository paths and commands +
# Current whole-snapshot reads
+nl -ba convex/snapshot.ts | sed -n '12,110p'
+
+# Current parent sequence write
+nl -ba convex/ops.ts | sed -n '17,29p'
+rg -n 'incrementSequence' convex/ops.ts
+
+# Current Flutter subscription
+nl -ba lib/collab/convex_strategy_repository.dart | sed -n '194,207p'
+nl -ba lib/providers/collab/remote_strategy_snapshot_provider.dart | sed -n '95,117p'
+
+# Existing page indexes and entity revisions
+nl -ba convex/schema.ts | sed -n '37,99p'
+

Verified against commit 277fc14 in worktree t3code-84485e64 on 2026-08-24.

+
+ +
+ Assumptions · decisions that need product confirmation +
    +
  • The active editor needs realtime content for one page at a time.
  • +
  • Page name, order, and side belong in the strategy shell because the page list displays them.
  • +
  • Canvas settings can move into pageContents without changing the exported .ica shape.
  • +
  • Library sorting does not need to change on every drag for the first beta. If it does, use a library-only activity record and keep it out of editor queries.
  • +
  • The current cloud deployment contains no public user data and can be wiped for the schema change.
  • +
+
+
+ +

Generated 2026-08-24 · Codex with Dara · inputs: commit 277fc14, current Convex schema, snapshot query, mutation path, Flutter sync providers, official Convex reactive query and OCC documentation · revision v1

+
+
+
+ + + + diff --git a/docs/cloud_sync_refactor/server_side_sync_boundaries_delivery.md b/docs/cloud_sync_refactor/server_side_sync_boundaries_delivery.md new file mode 100644 index 00000000..2bf41d22 --- /dev/null +++ b/docs/cloud_sync_refactor/server_side_sync_boundaries_delivery.md @@ -0,0 +1,115 @@ +# Delivery gate: verify and babysit page-scoped sync + +Open this file only after the automated gate in +[`server_side_sync_boundaries_handoff.md`](server_side_sync_boundaries_handoff.md) +passes. This is the final sequence. The work is incomplete until the last +criterion passes. + +## Run the visible proof with Computer Use + +Automated tests establish the contract. The visible proof establishes that the +app keeps its promise. + +Launch two clients with independent local persistence: + +- Client A: `fvm flutter run -d macos` +- Client B: `fvm flutter run -d chrome`, or a second physical device if the web + build cannot complete the current auth flow + +Two `open -n` copies of the macOS app are not independent. They share the same +Application Support directory and Hive boxes, so that setup cannot prove +remote convergence. + +Use the Computer Use skill for the native Icarus window. Start with a fresh app +state read, prefer accessibility elements, and fetch fresh state after every +action before reusing an element index. Use the product browser controls for +the web client when available. Screenshots support the written observations. +They do not replace them. + +Ask Dara to take over for credentials or an unexpected permission prompt. +Continue once both clients show the same signed-in cloud strategy. + +Create a strategy named `SYNC BOUNDARY PROBE` with pages `A ACTIVE` and +`B INACTIVE`. Give each page a distinct text element. Add an image or lineup +image to page B so asset scoping is exercised. + +Run and record these scenarios: + +| Scenario | Client actions | Required visible result | +| --- | --- | --- | +| inactive-page isolation | A stays on page A. B moves an element and edits a lineup on page B. | A's canvas does not rehydrate or flicker. Its page A content stays unchanged. Switching A to B reveals B's accepted edits. | +| same-page convergence | Both clients open page A and edit different entities. | Both edits land and both clients converge. The sync chip reaches `Synced` only after acknowledgements. | +| same-entity conflict | Both clients edit one entity from the same revision. | One edit rejects or rebases through the explicit conflict flow. The losing intent is visible. No silent overwrite occurs. | +| page structure | B renames, adds, reorders, then deletes a non-active page. | A's page list updates through the shell. The active canvas changes only if its page disappears. | +| page switch with pending work | A edits page A and immediately switches to B. | Navigation stays responsive. Page A intent remains queued or lands. Returning to A shows the intended content. | +| full round-trip | Export the cloud strategy, import it into a clean local library, then export it again. | Pages, settings, entities, lineups, and media match semantically. | + +For the restart proof, temporarily disconnecting the Mac is a network-setting +change. Computer Use must ask for confirmation at action time. With approval: + +1. Disconnect after both clients have loaded. +2. Edit page A and verify `Offline` or `Attention`, never `Synced`. +3. Quit and reopen Icarus. +4. Verify the pending edit and status survived. +5. Reconnect and verify the same op lands once. + +Without approval, run the equivalent durable-outbox provider test and mark the +visible restart scenario as not run with that exact reason. + +Done when every scenario has an observed result, the setup identifies both +clients, and any skipped path has a concrete reason. + +## Measure the resource boundary + +Open the linked Convex development dashboard and isolate a short test window. +Compare ten element moves on an active page in a two-page strategy with ten +moves on the same-sized active page in a many-page strategy. + +Record query calls, rows read, bytes read or returned, and mutation conflicts +where the dashboard exposes them. The result should scale with active-page +content, not inactive page count. Report observed numbers. Set no invented +percentage target. + +Done when the evidence names the function, test window, page and entity counts, +observed metrics, and any dashboard metric that was unavailable. + +## Open the PR + +Refresh `origin/icarus-cloud` before opening the PR. Rebase or merge only with +the repository's normal policy. Open the PR against `icarus-cloud`, not `main`. + +The body must include: + +- the shell and active-page boundary; +- the write matrix and replay rule; +- the development deployment reset; +- automated command results; +- Computer Use scenario results; +- Convex resource observations; +- known follow-ups outside this assignment. + +Done when the PR exists against the correct base and its body contains the +actual proof results rather than a checklist of work still to run. + +## Babysit the current PR head + +Invoke the `github-pr-babysitter` skill. Record the PR number, URL, branch, +current head SHA, checks, review bots, and thread-aware review state. Poll active +reviews instead of reading an older badge as current. + +For every new head: + +1. Wait for the current bot run and CI. +2. Read review threads with `isResolved` and `isOutdated` data. +3. Fix narrow actionable findings. +4. Re-run proof proportional to the change. +5. Commit only the intended files and push. +6. Trigger the documented re-review mechanism. +7. Confirm a new review actually starts for the new SHA. + +For Greptile, green means a current-head 5/5 result. A top-level summary is +insufficient while an actionable thread remains. Leave the PR unmerged. + +Done when CI passes on the current head, the latest automated review applies to +that head, no actionable unresolved thread remains, the branch is clean and +pushed, and Dara receives the PR URL, head SHA, review result, and proof run. diff --git a/docs/cloud_sync_refactor/server_side_sync_boundaries_handoff.md b/docs/cloud_sync_refactor/server_side_sync_boundaries_handoff.md new file mode 100644 index 00000000..c7d9c15c --- /dev/null +++ b/docs/cloud_sync_refactor/server_side_sync_boundaries_handoff.md @@ -0,0 +1,431 @@ +# Handoff: make Convex sync page-scoped + +Status: ready for implementation + +Verified base: `277fc14` on 2026-08-25 + +PR target: `icarus-cloud` + +## The assignment + +Deliver one reviewable PR that makes the normal editor sync one strategy shell +and one active page. Keep the full strategy snapshot as a one-shot read for +export, import verification, and recovery. Persist the operation outbox so an +unacknowledged edit survives a page switch, app restart, expired auth, and +network loss. + +The PR is at its midpoint when it opens. Finish by running the visible desktop +proof and babysitting the current PR head until CI and automated review have no +actionable findings. Leave the PR unmerged for Dara. + +Read these before editing: + +1. [`AGENTS.md`](../../AGENTS.md) for the data and product rules. +2. [`CONTEXT.md`](../../CONTEXT.md) for Icarus vocabulary. +3. [`server_side_sync_boundaries_blueprint.html`](server_side_sync_boundaries_blueprint.html) for the architectural reasoning and diagrams. +4. [`DESIGN.md`](../../DESIGN.md) before changing any visible state. +5. [`auth_flow_reference.md`](../auth_flow_reference.md) if the verification path reaches sign-in or token recovery. + +## Fixed decisions + +Treat these as the contract, not design prompts. + +- Convex remains the durable cloud library and collaboration authority. +- An open editor has two live queries: `strategy:getShell` and + `page:getSnapshot` for the active page. +- `strategy:getFullSnapshot` is a one-shot query. No provider subscribes to it. +- A new `pageContents` row owns mutable page settings. The `pages` row remains + the descriptor used by the page list. +- An element, lineup, or page-content edit writes its own row and its operation + event. It does not patch the parent strategy. +- Conflict checks use the revision of the record being edited. There is no + global content sequence and no page-level sequence. +- `strategies.revision` covers strategy metadata and page collection structure. + It does not advance when canvas content changes. +- The library `updatedAt` does not change on every drag. Add a separate library + activity record later only if a measured product need justifies it. +- The outbox keeps a failed op. After the retry budget it becomes paused and + visible. It never discards the op. +- Local mode, `.ica` files, and library backups keep their current shape and + behavior. + +The normal edit path should have one sentence worth remembering: + +> The cost and conflict range of an edit match the record the user edited. + +## Starting evidence + +The current implementation has four useful pieces and three liabilities. + +Useful pieces: + +- `active_page_live_sync_provider.dart` already projects queued and in-flight + local intent over a remote base. +- `strategy_op_queue_provider.dart` already coalesces intent by page and entity. +- Elements and lineups already have `by_pageId` indexes and per-row revisions. +- Operation events already deduplicate `strategyId + clientId + opId`. + +Liabilities: + +- `snapshot:get` reads the strategy, every page, every element, every lineup, + and every referenced asset as one reactive read set. +- `ops:applyBatch` advances `strategies.sequence` and `updatedAt` for ordinary + content edits. +- The outbox is memory-only, regenerates `clientId`, and removes an op after + eight failed attempts. + +The nearest named branches do not contain the proposed boundary. At the +verified base, both are already ancestors of this branch: + +- `origin/cursor/convex-image-asset-storage-3cc3` introduced the broader + strategy-level query refactor. +- `origin/cursor/cloud-page-sync-overlay-6677` introduced the active-page + overlay. + +Before implementation, refresh refs and search named branches for +`pageContents`, `getShell`, and `getFullSnapshot`. Reuse newer work if it exists. +Do not cherry-pick either branch listed above into this base. + +Baseline proof at the verified base: + +```text +npx tsc --noEmit +PASS + +fvm flutter test \ + test/strategy_page_session_provider_test.dart \ + test/strategy_op_queue_provider_test.dart \ + test/collab_sync_models_test.dart \ + test/cloud_ui_parity_helpers_test.dart \ + test/strategy_integrity_test.dart +PASS, 55 tests +``` + +Refresh this baseline before changing code. Record drift in the PR body. + +## Target server model + +| Record | Owns | Revision changes when | +| --- | --- | --- | +| `strategies` | identity, owner, folder, name, map, theme, page collection revision | strategy metadata changes, or a page is added, deleted, or reordered | +| `pages` | `publicId`, strategy membership, name, order, side | that page descriptor changes | +| `pageContents` | one page's settings | those settings change | +| `elements` | one placed canvas entity | that entity changes | +| `lineups` | one lineup group | that lineup changes | +| `imageAssets` | upload and delivery state | that asset changes | +| `operationEvents` | idempotency result for one op | once, when the server accepts or rejects the op | + +`pageContents` is one-to-one with a page: + +```text +pageId Id<"pages">, indexed by_pageId +settings strategySettingsValidator, optional +revision number +createdAt number +updatedAt number +``` + +Create the page descriptor and page content row in the same mutation. Delete +the page content row when deleting its page. Keep the existing orphan cleanup +for elements and lineups. + +## Target read contract + +| Query | Lifetime | Reads | Must not read | +| --- | --- | --- | --- | +| `strategy:getShell` | subscribed while the editor is open | authorized role, strategy metadata, ordered page descriptors | page settings, elements, lineups, image assets | +| `page:getSnapshot` | subscribed for one active page | page descriptor, its `pageContents`, its elements, its lineups, referenced assets | records belonging only to another page | +| `strategy:getFullSnapshot` | one-shot | every record required to reconstruct and export the strategy | nothing required for a lossless round-trip may be omitted | + +The page query takes both `strategyPublicId` and `pagePublicId`. It verifies +membership and viewer access before returning data. It uses `by_pageId` for +elements and lineups. It resolves image assets only from IDs referenced by the +active page's element and lineup payloads. + +Use separate client types for the separate jobs: + +- `RemoteStrategyShell` +- `RemotePageSnapshot` +- `RemoteStrategySnapshot` or `RemoteFullStrategySnapshot` for the full + one-shot result + +Do not make one large type whose fields become nullable depending on which +query produced it. Export must accept only the full snapshot type. + +## Target write contract + +Use `expectedRevision` consistently. Its authority is the target record: + +| Operation | Checks | Writes | Legitimate reactive update | +| --- | --- | --- | --- | +| strategy metadata patch | `strategies.revision` | strategy row and operation event | shell and library | +| page add, delete, reorder | `strategies.revision` | affected page rows, strategy revision, page content row when applicable, operation event | shell and library | +| page descriptor patch | `pages.revision` | one page row and operation event | shell, plus active page if it is open | +| page content patch | `pageContents.revision` | one page content row and operation event | that active page | +| element add, patch, move, delete | element revision when the row exists | one element row and operation event | that active page | +| lineup add, patch, move, delete | lineup revision when the row exists | one lineup row and operation event | that active page | + +Add `pageContent` as an operation entity type. Keep page descriptor and page +content intent in separate entity keys so coalescing cannot merge two revision +domains. The active-page controller may produce both intents when one local +action changes side and settings. + +Remove `expectedSequence` and `appliedSequence` from the protocol, server +validator, event schema, acknowledgement model, client models, and tests. A +strategy-level operation uses `expectedRevision` against +`strategies.revision`. + +### Replay safety + +Operation events are currently retained for 30 days. A durable outbox can live +longer, so every operation must remain safe after its event has expired. + +- Replayed add with identical existing content returns an acknowledged no-op. +- Replayed add with different existing content rejects. It does not become an + upsert over newer work. +- Replayed patch whose desired content already matches returns an acknowledged + no-op before revision rejection. +- Replayed patch against different newer content rejects on revision. +- Replayed delete against a missing row returns an acknowledged no-op. +- Replayed reorder that already matches returns an acknowledged no-op. + +Prove each case in server tests. Operation-event lookup remains the fast path. + +## Execution path + +### 1. Lock the proof before the refactor + +Refresh the base branch and named branches. Run the baseline commands. Add +focused tests for the new contract before changing production behavior. + +The server tests must cover: + +- the shell excludes page content and canvas records; +- one page snapshot excludes other pages and their assets; +- the full snapshot reconstructs every page and referenced asset; +- a content op leaves the strategy row unchanged; +- different entities and different pages can update from the same starting + strategy revision; +- two edits to the same entity from one revision produce one visible reject; +- page membership and reorder use the strategy revision; +- every replay case above is safe after the event row is absent. + +The repository has no Convex test harness at the verified base. Add the +smallest repository-local harness that executes the real Convex functions and +schema. Keep pure helper tests only for logic that cannot run through the real +functions. + +Done when the new tests fail for the current broad-query, parent-write, and +memory-only behavior while the refreshed baseline still passes. + +### 2. Reshape the empty development deployment + +Update source schema and validators. Add `pageContents`, replace the strategy +content sequence with `strategies.revision`, and update operation events. +Update all page creation and cloud migration paths in the same cut. + +The development deployment contains team data only and may be wiped under the +rules in `AGENTS.md`. Verify that assumption before the destructive action. +Regenerate Convex output with `npx convex dev`. Edit no file under +`convex/_generated/` by hand. + +Done when a clean development deployment accepts the schema, page creation +always creates exactly one page content row, and `npx tsc --noEmit` passes. + +### 3. Add the narrow reads + +Implement the three target queries. Keep authorization in each public query. +Keep sorting deterministic. Use the existing canonical payload and asset +reference helpers instead of introducing a second serializer. + +Delete or deprecate `snapshot:get` only after all client and export callers +have moved. No subscribed provider may retain it as a recovery fallback. A +failure recovery may issue a one-shot full snapshot, then return to shell and +active-page subscriptions. + +Done when server tests prove the read sets by result and index path, and a +repository search shows the editor no longer subscribes to `snapshot:get`. + +### 4. Narrow the writes + +Refactor `ops:applyBatch` around the target write table. Split page descriptor +and page content intent. Apply replay checks before stale-revision rejection +where an identical desired state is a safe no-op. + +Keep one mutation batch atomic. Keep operation events as the acknowledgement +source. A rejected op returns the latest target revision and payload needed by +the current client conflict flow. + +Done when tests prove that content edits do not change the strategy row or an +inactive page, while metadata and page structure edits still update the shell. + +### 5. Split the Flutter read model + +Add repository methods for the shell, active page, and full snapshot. Replace +`remoteStrategySnapshotProvider` with a shell provider and one active-page +provider, or leave a thin compatibility name only if its type and lifetime are +unambiguous. + +On a page switch: + +1. Project and persist the current page intent. +2. Attempt a flush without making navigation wait indefinitely. +3. Change the active page ID. +4. Cancel the old page subscription. +5. Subscribe to the new page and hydrate its remote base. +6. Reapply any persisted local intent for that page. + +Replace `_RemotePageHydrationKey` sequence gating with active-page revision and +content fingerprints. A page snapshot update should rehydrate its page without +waiting for a strategy header update. Preserve undo history and the local +overlay rules already covered by provider tests. + +Export, recovery, and cloud-to-local copy paths must call the one-shot full +snapshot. Their types must prevent an active-page result from reaching export. + +Done when one editor owns exactly two live subscriptions regardless of page +count, inactive-page updates do not rehydrate the canvas, and every existing +page-switch, overlay, conflict, media, and round-trip test passes. + +### 6. Make the outbox durable + +Use a dedicated Hive box containing versioned, JSON-safe records. Keep it out +of `StrategyData` so local library migrations and `.ica` serialization remain +unchanged. Each record contains at least: + +```text +outboxVersion +accountId +strategyPublicId +entityKey +clientId +opId +serialized op +attempts +createdAt +updatedAt +lastAttemptAt +lastError +state +``` + +The account ID prevents one signed-in user from submitting another user's +pending work on a shared computer. Persist `clientId` and `opId` across app +restarts so a crash after server acceptance reuses the operation-event key. + +Use this ordering: + +1. Persist new or coalesced desired intent. +2. Reflect it in provider state and render `Syncing`. +3. Send it while the durable record remains present. +4. On acknowledgement, remove the durable record. +5. On rejection, persist a replacement intent before removing the rejected + record, or keep the rejected record in `Attention` for user action. + +Load and validate the box before the sync chip may say `Synced`. A corrupt +record produces `Attention` with recovery detail. Sign-out, strategy switch, +provider disposal, and retry exhaustion retain unacknowledged records. + +Replace the eight-attempt discard with a paused state. Manual retry resumes the +same durable intent. The exit guard reads durable state, including pending work +for a page that is no longer active. + +Tests must cover these crash windows: + +- restart after enqueue and before send; +- restart while an op is in flight; +- restart after server acceptance and before local removal; +- strategy switch with another page's pending op; +- auth expiry and recovery; +- retry exhaustion followed by manual retry; +- corrupt persisted record; +- sign-out and sign-in as the same account; +- sign-in as a different account. + +Done when no unacknowledged intent exists only in memory and retry exhaustion +cannot discard it. + +### 7. Run the automated gate + +Run focused tests while iterating, then run the full gate from the repository +root: + +```bash +npx tsc --noEmit +fvm flutter test \ + test/strategy_page_session_provider_test.dart \ + test/strategy_op_queue_provider_test.dart \ + test/collab_sync_models_test.dart \ + test/cloud_ui_parity_helpers_test.dart \ + test/strategy_integrity_test.dart +fvm flutter test +fvm flutter analyze --no-fatal-infos +git diff --check +``` + +If the Convex harness has a separate command, add it before the Flutter tests +and to CI. Record every command and result in the PR body. Existing warnings +may remain only when the refreshed base has the same warning. + +Done when the full gate passes and the diff contains no hand-edited generated +file, accidental lockfile churn, or unrelated worktree changes. + +### 8. Finish visible proof and PR delivery + +After the automated gate passes, read +[`server_side_sync_boundaries_delivery.md`](server_side_sync_boundaries_delivery.md). +It contains the required Computer Use scenarios, Convex resource measurement, +PR contents, and current-head review loop. Opening the PR before completing its +visible proof is premature. + +Done when every completion criterion in the delivery runbook passes. Leave the +PR unmerged for Dara. + +## Stop and escalate + +Stop implementation and ask Dara when any of these becomes true: + +- the linked Convex deployment contains public user data; +- the proposed server model would change local `.ica` or backup shape; +- another branch now implements the same boundary with incompatible choices; +- a generated-file change cannot be reproduced by its generator; +- preserving pending intent requires silently choosing between two users' + conflicting edits; +- the PR target has moved away from `icarus-cloud`; +- unrelated dirty work overlaps a file this refactor must rewrite. + +## File map + +Start here rather than scanning the whole app: + +```text +convex/schema.ts +convex/snapshot.ts +convex/ops.ts +convex/pages.ts +convex/strategies.ts +convex/lib/opTypes.ts +convex/lib/payloadValidators.ts +convex/maintenance.ts + +lib/collab/collab_models.dart +lib/collab/convex_strategy_repository.dart +lib/providers/collab/remote_strategy_snapshot_provider.dart +lib/providers/collab/active_page_live_sync_models.dart +lib/providers/collab/active_page_live_sync_provider.dart +lib/providers/collab/strategy_op_queue_provider.dart +lib/providers/collab/strategy_conflict_provider.dart +lib/providers/strategy_page_session_provider.dart +lib/strategy/strategy_page_source.dart +lib/strategy/strategy_import_export.dart +lib/widgets/cloud_sync_status_chip.dart +lib/services/unsaved_strategy_guard.dart + +test/strategy_page_session_provider_test.dart +test/strategy_op_queue_provider_test.dart +test/collab_sync_models_test.dart +test/strategy_integrity_test.dart +``` + +The blueprint explains why this boundary is the right one. This handoff defines +what must be true before the work is finished. diff --git a/lib/collab/collab_models.dart b/lib/collab/collab_models.dart index 2367fe40..fadd8c7a 100644 --- a/lib/collab/collab_models.dart +++ b/lib/collab/collab_models.dart @@ -2,9 +2,9 @@ import 'dart:convert'; enum StrategyOpKind { add, move, patch, delete, reorder } -enum StrategyOpEntityType { strategy, page, element, lineup } +enum StrategyOpEntityType { strategy, page, pageContent, element, lineup } -const currentCloudProtocolVersion = 1; +const currentCloudProtocolVersion = 2; const currentCloudPayloadVersion = 1; typedef CloudPayload = Map; @@ -43,12 +43,12 @@ Map cloudPayloadData(Object? payload) { if (payload is Map) { final data = payload['data']; if (data is Map) { - return data; + return _normalizeCloudPayloadData(data); } if (data is Map) { - return Map.from(data); + return _normalizeCloudPayloadData(Map.from(data)); } - return payload; + return _normalizeCloudPayloadData(payload); } if (payload is Map) { return cloudPayloadData(Map.from(payload)); @@ -91,7 +91,6 @@ class StrategyOp { this.payload, this.sortIndex, this.expectedRevision, - this.expectedSequence, }); final String opId; @@ -102,7 +101,6 @@ class StrategyOp { final Object? payload; final int? sortIndex; final int? expectedRevision; - final int? expectedSequence; Map toConvexJson() { return { @@ -114,13 +112,25 @@ class StrategyOp { if (payload != null) 'payload': payload, if (sortIndex != null) 'sortIndex': sortIndex, if (expectedRevision != null) 'expectedRevision': expectedRevision, - if (expectedSequence != null) 'expectedSequence': expectedSequence, }; } + factory StrategyOp.fromJson(Map json) { + return StrategyOp( + opId: json['opId'] as String, + kind: StrategyOpKind.values.byName(json['kind'] as String), + entityType: + StrategyOpEntityType.values.byName(json['entityType'] as String), + entityPublicId: json['entityPublicId'] as String?, + pagePublicId: json['pagePublicId'] as String?, + payload: json['payload'], + sortIndex: (json['sortIndex'] as num?)?.toInt(), + expectedRevision: (json['expectedRevision'] as num?)?.toInt(), + ); + } + StrategyOp copyWith({ int? expectedRevision, - int? expectedSequence, }) { return StrategyOp( opId: opId, @@ -131,7 +141,6 @@ class StrategyOp { payload: payload, sortIndex: sortIndex, expectedRevision: expectedRevision ?? this.expectedRevision, - expectedSequence: expectedSequence ?? this.expectedSequence, ); } } @@ -164,8 +173,6 @@ class OpAck { required this.opId, required this.status, this.reason, - this.appliedSequence, - this.latestSequence, this.appliedRevision, this.latestRevision, this.latestPayload, @@ -174,8 +181,6 @@ class OpAck { final String opId; final String status; final String? reason; - final int? appliedSequence; - final int? latestSequence; final int? appliedRevision; final int? latestRevision; final CloudPayload? latestPayload; @@ -187,8 +192,6 @@ class OpAck { opId: json['opId'] as String, status: json['status'] as String, reason: json['reason'] as String?, - appliedSequence: (json['appliedSequence'] as num?)?.toInt(), - latestSequence: (json['latestSequence'] as num?)?.toInt(), appliedRevision: (json['appliedRevision'] as num?)?.toInt(), latestRevision: (json['latestRevision'] as num?)?.toInt(), latestPayload: json['latestPayload'] == null @@ -207,7 +210,6 @@ class ConflictResolution { this.message, this.serverPayload, this.serverRevision, - this.serverSequence, }); final ConflictResolutionType type; @@ -215,7 +217,6 @@ class ConflictResolution { final String? message; final Map? serverPayload; final int? serverRevision; - final int? serverSequence; } class RemoteStrategyHeader { @@ -223,7 +224,7 @@ class RemoteStrategyHeader { required this.publicId, required this.name, required this.mapData, - required this.sequence, + required this.revision, required this.createdAt, required this.updatedAt, this.themeProfileId, @@ -234,7 +235,7 @@ class RemoteStrategyHeader { final String publicId; final String name; final String mapData; - final int sequence; + final int revision; final DateTime createdAt; final DateTime updatedAt; final String? themeProfileId; @@ -246,7 +247,7 @@ class RemoteStrategyHeader { publicId: json['publicId'] as String, name: json['name'] as String, mapData: json['mapData'] as String, - sequence: (json['sequence'] as num?)?.toInt() ?? 0, + revision: (json['revision'] as num?)?.toInt() ?? 0, createdAt: DateTime.fromMillisecondsSinceEpoch( (json['createdAt'] as num?)?.toInt() ?? 0, ), @@ -269,7 +270,8 @@ class RemotePage { required this.sortIndex, required this.isAttack, required this.revision, - this.settings, + required this.createdAt, + required this.updatedAt, }); final String publicId; @@ -278,7 +280,8 @@ class RemotePage { final int sortIndex; final bool isAttack; final int revision; - final CloudPayload? settings; + final DateTime createdAt; + final DateTime updatedAt; factory RemotePage.fromJson(Map json) { return RemotePage( @@ -288,7 +291,39 @@ class RemotePage { sortIndex: (json['sortIndex'] as num).toInt(), isAttack: json['isAttack'] as bool? ?? true, revision: (json['revision'] as num?)?.toInt() ?? 0, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (json['createdAt'] as num?)?.toInt() ?? 0, + ), + updatedAt: DateTime.fromMillisecondsSinceEpoch( + (json['updatedAt'] as num?)?.toInt() ?? 0, + ), + ); + } +} + +class RemotePageContent { + const RemotePageContent({ + required this.revision, + required this.createdAt, + required this.updatedAt, + this.settings, + }); + + final CloudPayload? settings; + final int revision; + final DateTime createdAt; + final DateTime updatedAt; + + factory RemotePageContent.fromJson(Map json) { + return RemotePageContent( settings: cloudObjectPayloadOrNull(json['settings']), + revision: (json['revision'] as num?)?.toInt() ?? 0, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (json['createdAt'] as num?)?.toInt() ?? 0, + ), + updatedAt: DateTime.fromMillisecondsSinceEpoch( + (json['updatedAt'] as num?)?.toInt() ?? 0, + ), ); } } @@ -410,69 +445,91 @@ class RemoteImageAsset { } } -class RemoteStrategySnapshot { - const RemoteStrategySnapshot({ +class RemoteStrategyShell { + const RemoteStrategyShell({ required this.header, required this.pages, - required this.elementsByPage, - required this.lineupsByPage, - required this.assetsById, }); final RemoteStrategyHeader header; final List pages; - final Map> elementsByPage; - final Map> lineupsByPage; +} + +class RemotePageSnapshot { + const RemotePageSnapshot({ + required this.page, + required this.content, + required this.elements, + required this.lineups, + required this.assetsById, + }); + + final RemotePage page; + final RemotePageContent content; + final List elements; + final List lineups; final Map assetsById; +} + +/// The bounded live editor state: one strategy shell and at most one page body. +class RemoteEditorSnapshot { + const RemoteEditorSnapshot({ + required this.shell, + required this.activePage, + }); - RemoteStrategySnapshot copyWith({ - RemoteStrategyHeader? header, - List? pages, - Map>? elementsByPage, - Map>? lineupsByPage, - Map? assetsById, + final RemoteStrategyShell shell; + final RemotePageSnapshot? activePage; + + RemoteStrategyHeader get header => shell.header; + List get pages => shell.pages; + Map> get elementsByPage => activePage == null + ? const >{} + : >{ + activePage!.page.publicId: activePage!.elements, + }; + Map> get lineupsByPage => activePage == null + ? const >{} + : >{ + activePage!.page.publicId: activePage!.lineups, + }; + Map get assetsById => + activePage?.assetsById ?? const {}; + + RemoteEditorSnapshot copyWith({ + RemoteStrategyShell? shell, + RemotePageSnapshot? activePage, + bool clearActivePage = false, }) { - return RemoteStrategySnapshot( - header: header ?? this.header, - pages: pages ?? this.pages, - elementsByPage: elementsByPage ?? this.elementsByPage, - lineupsByPage: lineupsByPage ?? this.lineupsByPage, - assetsById: assetsById ?? this.assetsById, + return RemoteEditorSnapshot( + shell: shell ?? this.shell, + activePage: clearActivePage ? null : (activePage ?? this.activePage), ); } +} - RemoteStrategySnapshot replaceHeader(RemoteStrategyHeader next) { - return copyWith(header: next); - } - - RemoteStrategySnapshot replacePages(List next) { - final pageIds = next.map((page) => page.publicId).toSet(); - return copyWith( - pages: next, - elementsByPage: Map>.fromEntries( - elementsByPage.entries.where((entry) => pageIds.contains(entry.key)), - ), - lineupsByPage: Map>.fromEntries( - lineupsByPage.entries.where((entry) => pageIds.contains(entry.key)), - ), - ); - } +class RemoteFullPage { + const RemoteFullPage({required this.page, required this.content}); - RemoteStrategySnapshot replaceAssets(List next) { - return copyWith( - assetsById: { - for (final asset in next) asset.publicId: asset, - }, - ); - } + final RemotePage page; + final RemotePageContent content; +} - RemoteStrategySnapshot replaceElements(List next) { - return copyWith(elementsByPage: groupElementsByPage(next)); - } +/// A one-shot whole-strategy value used only by explicit export/import flows. +class RemoteFullStrategySnapshot { + const RemoteFullStrategySnapshot({ + required this.header, + required this.pages, + required this.elementsByPage, + required this.lineupsByPage, + required this.assetsById, + }); - RemoteStrategySnapshot replaceLineups(List next) { - return copyWith(lineupsByPage: groupLineupsByPage(next)); - } + final RemoteStrategyHeader header; + final List pages; + final Map> elementsByPage; + final Map> lineupsByPage; + final Map assetsById; static Map> groupElementsByPage( Iterable elements, @@ -506,7 +563,7 @@ class CloudStrategySummary { required this.publicId, required this.name, required this.mapData, - required this.sequence, + required this.revision, required this.createdAt, required this.updatedAt, this.role, @@ -516,7 +573,7 @@ class CloudStrategySummary { final String publicId; final String name; final String mapData; - final int sequence; + final int revision; final DateTime createdAt; final DateTime updatedAt; final String? role; @@ -527,7 +584,7 @@ class CloudStrategySummary { publicId: json['publicId'] as String, name: json['name'] as String, mapData: json['mapData'] as String, - sequence: (json['sequence'] as num?)?.toInt() ?? 0, + revision: (json['revision'] as num?)?.toInt() ?? 0, createdAt: DateTime.fromMillisecondsSinceEpoch( (json['createdAt'] as num?)?.toInt() ?? 0, ), diff --git a/lib/collab/convex_client.dart b/lib/collab/convex_client.dart new file mode 100644 index 00000000..5c58e5f2 --- /dev/null +++ b/lib/collab/convex_client.dart @@ -0,0 +1,3 @@ +export 'src/convex_client_types.dart'; +export 'src/convex_client_native.dart' + if (dart.library.js_interop) 'src/convex_client_web.dart'; diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart index 361ba777..b9950c47 100644 --- a/lib/collab/convex_strategy_repository.dart +++ b/lib/collab/convex_strategy_repository.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'package:convex_flutter/convex_flutter.dart'; +import 'package:icarus/collab/convex_client.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/collab/collab_models.dart'; @@ -191,28 +191,94 @@ class ConvexStrategyRepository { return controller.stream; } - Future fetchSnapshot(String strategyPublicId) async { - final response = await _client.query('snapshot:get', { + Future fetchShell(String strategyPublicId) async { + final response = await _client.query('strategy:getShell', { 'strategyPublicId': strategyPublicId, }); - return _decodeSnapshot(_decodeObject(response)); + return _decodeShell(_decodeObject(response)); } - Stream watchSnapshot(String strategyPublicId) { + Stream watchShell(String strategyPublicId) { return _watchObject( - name: 'snapshot:get', + name: 'strategy:getShell', args: {'strategyPublicId': strategyPublicId}, - fromJson: _decodeSnapshot, + fromJson: _decodeShell, ); } - RemoteStrategySnapshot _decodeSnapshot(Map value) { - final header = - RemoteStrategyHeader.fromJson(_decodeObject(value['header'])); - // snapshot:get returns pages, elements, and lineups ordered by sortIndex. - final pages = _decodeObjectList(value['pages']) - .map(RemotePage.fromJson) + RemoteStrategyShell _decodeShell(Map value) { + return RemoteStrategyShell( + header: RemoteStrategyHeader.fromJson(_decodeObject(value['header'])), + pages: _decodeObjectList(value['pages']) + .map(RemotePage.fromJson) + .toList(growable: false), + ); + } + + Future fetchPageSnapshot({ + required String strategyPublicId, + required String pagePublicId, + }) async { + final response = await _client.query('page:getSnapshot', { + 'strategyPublicId': strategyPublicId, + 'pagePublicId': pagePublicId, + }); + return _decodePageSnapshot(_decodeObject(response)); + } + + Stream watchPageSnapshot({ + required String strategyPublicId, + required String pagePublicId, + }) { + return _watchObject( + name: 'page:getSnapshot', + args: { + 'strategyPublicId': strategyPublicId, + 'pagePublicId': pagePublicId, + }, + fromJson: _decodePageSnapshot, + ); + } + + RemotePageSnapshot _decodePageSnapshot(Map value) { + final assets = _decodeObjectList(value['assets']) + .map(RemoteImageAsset.fromJson) .toList(growable: false); + return RemotePageSnapshot( + page: RemotePage.fromJson(_decodeObject(value['page'])), + content: RemotePageContent.fromJson(_decodeObject(value['content'])), + elements: _decodeObjectList(value['elements']) + .map(RemoteElement.fromJson) + .toList(growable: false), + lineups: _decodeObjectList(value['lineups']) + .map(RemoteLineup.fromJson) + .toList(growable: false), + assetsById: {for (final asset in assets) asset.publicId: asset}, + ); + } + + Future fetchFullSnapshot( + String strategyPublicId, + ) async { + final response = await _client.query('strategy:getFullSnapshot', { + 'strategyPublicId': strategyPublicId, + }); + return _decodeFullSnapshot(_decodeObject(response)); + } + + RemoteFullStrategySnapshot _decodeFullSnapshot(Map value) { + final pages = _decodeObjectList(value['pages']).map((json) { + final page = RemotePage.fromJson(json); + return RemoteFullPage( + page: page, + content: RemotePageContent.fromJson({ + 'settings': json['settings'], + 'revision': json['contentRevision'], + 'createdAt': json['contentCreatedAt'], + 'updatedAt': json['contentUpdatedAt'], + }), + ); + }).toList(growable: false); final elements = _decodeObjectList(value['elements']) .map(RemoteElement.fromJson) .toList(growable: false); @@ -223,11 +289,11 @@ class ConvexStrategyRepository { .map(RemoteImageAsset.fromJson) .toList(growable: false); - return RemoteStrategySnapshot( - header: header, + return RemoteFullStrategySnapshot( + header: RemoteStrategyHeader.fromJson(_decodeObject(value['header'])), pages: pages, - elementsByPage: RemoteStrategySnapshot.groupElementsByPage(elements), - lineupsByPage: RemoteStrategySnapshot.groupLineupsByPage(lineups), + elementsByPage: RemoteFullStrategySnapshot.groupElementsByPage(elements), + lineupsByPage: RemoteFullStrategySnapshot.groupLineupsByPage(lineups), assetsById: { for (final asset in assets) asset.publicId: asset, }, diff --git a/lib/collab/durable_strategy_outbox.dart b/lib/collab/durable_strategy_outbox.dart new file mode 100644 index 00000000..f09aea2d --- /dev/null +++ b/lib/collab/durable_strategy_outbox.dart @@ -0,0 +1,263 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; + +const durableOutboxRecordVersion = 1; + +enum DurableOutboxStatus { queued, inFlight, paused, attention } + +class DurableOutboxRecord { + const DurableOutboxRecord({ + required this.accountId, + required this.strategyPublicId, + required this.entityKey, + required this.pending, + required this.status, + required this.createdAt, + required this.updatedAt, + this.lastError, + this.latestServerRevision, + }); + + final String accountId; + final String strategyPublicId; + final EntitySyncKey entityKey; + final PendingOp pending; + final DurableOutboxStatus status; + final DateTime createdAt; + final DateTime updatedAt; + final String? lastError; + final int? latestServerRevision; + + String get storageKey => createStorageKey( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: entityKey, + ); + + static String createStorageKey({ + required String accountId, + required String strategyPublicId, + required EntitySyncKey entityKey, + }) { + return '${Uri.encodeComponent(accountId)}|' + '${Uri.encodeComponent(strategyPublicId)}|$entityKey'; + } + + DurableOutboxRecord copyWith({ + PendingOp? pending, + DurableOutboxStatus? status, + DateTime? updatedAt, + String? lastError, + bool clearError = false, + int? latestServerRevision, + bool clearLatestServerRevision = false, + }) { + return DurableOutboxRecord( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: entityKey, + pending: pending ?? this.pending, + status: status ?? this.status, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastError: clearError ? null : (lastError ?? this.lastError), + latestServerRevision: clearLatestServerRevision + ? null + : (latestServerRevision ?? this.latestServerRevision), + ); + } + + Map toJson() => { + 'outboxVersion': durableOutboxRecordVersion, + 'accountId': accountId, + 'strategyPublicId': strategyPublicId, + 'entityKey': entityKey.toString(), + 'clientId': pending.clientId, + 'opId': pending.op.opId, + 'op': pending.op.toConvexJson(), + 'attempts': pending.attempts, + if (pending.lastAttemptAt != null) + 'lastAttemptAt': pending.lastAttemptAt!.toUtc().toIso8601String(), + 'status': status.name, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'updatedAt': updatedAt.toUtc().toIso8601String(), + if (lastError != null) 'lastError': lastError, + if (latestServerRevision != null) + 'latestServerRevision': latestServerRevision, + }; + + factory DurableOutboxRecord.fromJson(Map json) { + final version = (json['outboxVersion'] as num?)?.toInt(); + if (version != durableOutboxRecordVersion) { + throw FormatException('Unsupported outbox record version: $version'); + } + final opJson = _object(json['op'], field: 'op'); + final op = StrategyOp.fromJson(opJson); + if (json['opId'] != op.opId) { + throw const FormatException('Outbox opId does not match serialized op'); + } + final entityKey = EntitySyncKey.forStrategyOp(op); + if (entityKey == null || entityKey.toString() != json['entityKey']) { + throw const FormatException('Outbox entity key does not match op'); + } + return DurableOutboxRecord( + accountId: _nonEmptyString(json['accountId'], field: 'accountId'), + strategyPublicId: + _nonEmptyString(json['strategyPublicId'], field: 'strategyPublicId'), + entityKey: entityKey, + pending: PendingOp( + op: op, + clientId: _nonEmptyString(json['clientId'], field: 'clientId'), + attempts: (json['attempts'] as num?)?.toInt() ?? 0, + lastAttemptAt: _optionalDate(json['lastAttemptAt']), + ), + status: DurableOutboxStatus.values.byName( + _nonEmptyString(json['status'], field: 'status'), + ), + createdAt: _requiredDate(json['createdAt'], field: 'createdAt'), + updatedAt: _requiredDate(json['updatedAt'], field: 'updatedAt'), + lastError: json['lastError'] as String?, + latestServerRevision: (json['latestServerRevision'] as num?)?.toInt(), + ); + } + + static Map _object(Object? value, {required String field}) { + if (value is Map) return value; + if (value is Map) return Map.from(value); + throw FormatException('Outbox $field must be an object'); + } + + static String _nonEmptyString(Object? value, {required String field}) { + if (value is String && value.isNotEmpty) return value; + throw FormatException('Outbox $field must be a non-empty string'); + } + + static DateTime _requiredDate(Object? value, {required String field}) { + final parsed = _optionalDate(value); + if (parsed != null) return parsed; + throw FormatException('Outbox $field must be an ISO-8601 date'); + } + + static DateTime? _optionalDate(Object? value) { + if (value is! String) return null; + return DateTime.tryParse(value)?.toLocal(); + } +} + +class DurableOutboxLoadIssue { + const DurableOutboxLoadIssue({required this.storageKey, required this.error}); + + final String storageKey; + final String error; +} + +class DurableOutboxLoadResult { + const DurableOutboxLoadResult({required this.records, required this.issues}); + + final List records; + final List issues; +} + +abstract class DurableStrategyOutboxStore { + DurableOutboxLoadResult load(); + Future put(DurableOutboxRecord record); + Future remove(String storageKey); +} + +class HiveDurableStrategyOutboxStore implements DurableStrategyOutboxStore { + Box get _box => Hive.box(HiveBoxNames.strategyOutboxBox); + + @override + DurableOutboxLoadResult load() { + final records = []; + final issues = []; + for (final key in _box.keys) { + final storageKey = key.toString(); + try { + final raw = _box.get(key); + final decoded = raw is String ? jsonDecode(raw) : raw; + final json = decoded is Map + ? decoded + : Map.from(decoded as Map); + final record = DurableOutboxRecord.fromJson(json); + if (record.storageKey != storageKey) { + throw const FormatException( + 'Outbox storage key does not match record'); + } + records.add(record); + } catch (error) { + issues.add(DurableOutboxLoadIssue( + storageKey: storageKey, + error: error.toString(), + )); + } + } + return DurableOutboxLoadResult(records: records, issues: issues); + } + + @override + Future put(DurableOutboxRecord record) { + final jsonSafe = Map.from( + jsonDecode(jsonEncode(record.toJson())) as Map, + ); + return _box.put(record.storageKey, jsonSafe); + } + + @override + Future remove(String storageKey) => _box.delete(storageKey); +} + +class MemoryDurableStrategyOutboxStore implements DurableStrategyOutboxStore { + MemoryDurableStrategyOutboxStore([ + Map? initialValues, + ]) : values = Map.from(initialValues ?? const {}); + + final Map values; + + @override + DurableOutboxLoadResult load() { + final records = []; + final issues = []; + for (final entry in values.entries) { + try { + final value = entry.value; + final json = value is Map + ? value + : Map.from(value as Map); + final record = DurableOutboxRecord.fromJson(json); + if (record.storageKey != entry.key) { + throw const FormatException( + 'Outbox storage key does not match record'); + } + records.add(record); + } catch (error) { + issues.add(DurableOutboxLoadIssue( + storageKey: entry.key, + error: error.toString(), + )); + } + } + return DurableOutboxLoadResult(records: records, issues: issues); + } + + @override + Future put(DurableOutboxRecord record) async { + values[record.storageKey] = Map.from( + jsonDecode(jsonEncode(record.toJson())) as Map, + ); + } + + @override + Future remove(String storageKey) async { + values.remove(storageKey); + } +} + +final durableStrategyOutboxStoreProvider = Provider( + (ref) => HiveDurableStrategyOutboxStore(), +); diff --git a/lib/collab/src/convex_client_native.dart b/lib/collab/src/convex_client_native.dart new file mode 100644 index 00000000..d38813b0 --- /dev/null +++ b/lib/collab/src/convex_client_native.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:convex_flutter/convex_flutter.dart' as native; +import 'package:icarus/collab/src/convex_client_types.dart'; + +class ConvexClient { + ConvexClient._(this._client, this.config); + + final native.ConvexClient _client; + final ConvexConfig config; + + static ConvexClient? _instance; + + static ConvexClient get instance { + final client = _instance; + if (client == null) { + throw StateError('ConvexClient has not been initialized.'); + } + return client; + } + + static Future initialize(ConvexConfig config) async { + await native.ConvexClient.initialize( + native.ConvexConfig( + deploymentUrl: config.deploymentUrl, + clientId: config.clientId, + operationTimeout: config.operationTimeout, + healthCheckQuery: config.healthCheckQuery, + ), + ); + _instance = ConvexClient._(native.ConvexClient.instance, config); + } + + Stream get connectionState => + _client.connectionState.map(_mapConnectionState); + + WebSocketConnectionState get currentConnectionState => + _mapConnectionState(_client.currentConnectionState); + + bool get isConnected => _client.isConnected; + + Stream get authState => _client.authState; + + bool get isAuthenticated => _client.isAuthenticated; + + Future query(String name, Map args) => + _client.query(name, args); + + Future mutation({ + required String name, + required Map args, + }) => + _client.mutation(name: name, args: args); + + Future action({ + required String name, + required Map args, + }) => + _client.action(name: name, args: args); + + Future subscribe({ + required String name, + required Map args, + required void Function(String value) onUpdate, + required void Function(String message, String? value) onError, + }) async { + final handle = await _client.subscribe( + name: name, + args: args, + onUpdate: onUpdate, + onError: onError, + ); + return _NativeSubscriptionHandle(handle); + } + + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool isAuthenticated)? onAuthChange, + }) async { + final handle = await _client.setAuthWithRefresh( + fetchToken: fetchToken, + onAuthChange: onAuthChange, + ); + return _NativeAuthHandleWrapper(handle); + } + + Future clearAuth() => _client.clearAuth(); + + Future reconnect() => _client.reconnect(); + + void dispose() => _client.dispose(); + + static WebSocketConnectionState _mapConnectionState( + native.WebSocketConnectionState state, + ) { + return state == native.WebSocketConnectionState.connected + ? WebSocketConnectionState.connected + : WebSocketConnectionState.connecting; + } +} + +class _NativeSubscriptionHandle implements SubscriptionHandle { + _NativeSubscriptionHandle(this._handle); + + final native.SubscriptionHandle _handle; + + @override + void cancel() => _handle.cancel(); +} + +class _NativeAuthHandleWrapper implements AuthHandleWrapper { + _NativeAuthHandleWrapper(this._handle); + + final native.AuthHandleWrapper _handle; + + @override + void dispose() => _handle.dispose(); +} diff --git a/lib/collab/src/convex_client_types.dart b/lib/collab/src/convex_client_types.dart new file mode 100644 index 00000000..09d98483 --- /dev/null +++ b/lib/collab/src/convex_client_types.dart @@ -0,0 +1,26 @@ +class ConvexConfig { + const ConvexConfig({ + required this.deploymentUrl, + required this.clientId, + this.operationTimeout = const Duration(seconds: 30), + this.healthCheckQuery, + }); + + final String deploymentUrl; + final String clientId; + final Duration operationTimeout; + final String? healthCheckQuery; +} + +enum WebSocketConnectionState { + connecting, + connected, +} + +abstract interface class SubscriptionHandle { + void cancel(); +} + +abstract interface class AuthHandleWrapper { + void dispose(); +} diff --git a/lib/collab/src/convex_client_web.dart b/lib/collab/src/convex_client_web.dart new file mode 100644 index 00000000..370ae9e6 --- /dev/null +++ b/lib/collab/src/convex_client_web.dart @@ -0,0 +1,285 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:icarus/collab/src/convex_client_types.dart'; + +@JS('convex.ConvexClient') +extension type _JsConvexClient._(JSObject _) implements JSObject { + external factory _JsConvexClient(String address); + + external JSPromise query(String name, JSAny? args); + external JSPromise mutation(String name, JSAny? args); + external JSPromise action(String name, JSAny? args); + external JSFunction onUpdate( + String name, + JSAny? args, + JSFunction onUpdate, [ + JSFunction? onError, + ]); + external void setAuth(JSFunction fetchToken, [JSFunction? onChange]); + external void clearAuth(); + external _JsConnectionState connectionState(); + external JSFunction subscribeToConnectionState(JSFunction callback); + external void close(); +} + +@JS() +extension type _JsConnectionState._(JSObject _) implements JSObject { + external bool get isWebSocketConnected; +} + +class ConvexClient { + ConvexClient._(this._client, this.config) { + _setConnectionState( + _client.connectionState().isWebSocketConnected + ? WebSocketConnectionState.connected + : WebSocketConnectionState.connecting, + ); + _connectionStateCallback = ((JSAny? value) { + final connection = _JsConnectionState._(value as JSObject); + _setConnectionState( + connection.isWebSocketConnected + ? WebSocketConnectionState.connected + : WebSocketConnectionState.connecting, + ); + }).toJS; + _connectionStateUnsubscribe = + _client.subscribeToConnectionState(_connectionStateCallback!); + } + + final _JsConvexClient _client; + final ConvexConfig config; + final StreamController _connectionStateController = + StreamController.broadcast(); + final StreamController _authStateController = + StreamController.broadcast(); + + WebSocketConnectionState _connectionState = + WebSocketConnectionState.connecting; + bool _isAuthenticated = false; + int _authGeneration = 0; + JSFunction? _connectionStateCallback; + JSFunction? _connectionStateUnsubscribe; + bool _disposed = false; + + static ConvexClient? _instance; + + static ConvexClient get instance { + final client = _instance; + if (client == null) { + throw StateError('ConvexClient has not been initialized.'); + } + return client; + } + + static Future initialize(ConvexConfig config) async { + _instance = ConvexClient._(_JsConvexClient(config.deploymentUrl), config); + } + + Stream get connectionState => + _connectionStateController.stream; + + WebSocketConnectionState get currentConnectionState => _connectionState; + + bool get isConnected => + _connectionState == WebSocketConnectionState.connected; + + Stream get authState => _authStateController.stream; + + bool get isAuthenticated => _isAuthenticated; + + Future query(String name, Map args) { + return _awaitResult('Query $name', _client.query(name, args.jsify())); + } + + Future mutation({ + required String name, + required Map args, + }) { + return _awaitResult( + 'Mutation $name', + _client.mutation(name, args.jsify()), + ); + } + + Future action({ + required String name, + required Map args, + }) { + return _awaitResult('Action $name', _client.action(name, args.jsify())); + } + + Future subscribe({ + required String name, + required Map args, + required void Function(String value) onUpdate, + required void Function(String message, String? value) onError, + }) async { + final updateCallback = ((JSAny? value, JSAny? _) { + onUpdate(jsonEncode(value?.dartify())); + }).toJS; + final errorCallback = ((JSAny? error, JSAny? _) { + onError(_jsErrorMessage(error), null); + }).toJS; + final unsubscribe = _client.onUpdate( + name, + args.jsify(), + updateCallback, + errorCallback, + ); + return _WebSubscriptionHandle( + unsubscribe: unsubscribe, + updateCallback: updateCallback, + errorCallback: errorCallback, + ); + } + + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool isAuthenticated)? onAuthChange, + }) async { + final generation = ++_authGeneration; + final tokenCallback = ((JSAny? _) { + return fetchToken().then((token) => token?.toJS).toJS; + }).toJS; + final authCallback = ((JSBoolean authenticated) { + if (generation != _authGeneration) { + return; + } + final value = authenticated.toDart; + _setAuthenticated(value); + onAuthChange?.call(value); + }).toJS; + _client.setAuth(tokenCallback, authCallback); + return _WebAuthHandleWrapper( + onDispose: () { + if (generation != _authGeneration) { + return; + } + _authGeneration += 1; + _client.clearAuth(); + _setAuthenticated(false); + }, + tokenCallback: tokenCallback, + authCallback: authCallback, + ); + } + + Future clearAuth() async { + _authGeneration += 1; + _client.clearAuth(); + _setAuthenticated(false); + } + + Future reconnect() async { + if (isConnected) { + return true; + } + try { + await connectionState + .firstWhere((state) => state == WebSocketConnectionState.connected) + .timeout(const Duration(seconds: 5)); + return true; + } on TimeoutException { + return false; + } + } + + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + _connectionStateUnsubscribe?.callAsFunction(); + _connectionStateUnsubscribe = null; + _connectionStateCallback = null; + _client.close(); + _connectionStateController.close(); + _authStateController.close(); + } + + Future _awaitResult( + String operation, + JSPromise promise, + ) async { + try { + final result = await promise.toDart.timeout(config.operationTimeout); + return jsonEncode(result?.dartify()); + } on TimeoutException { + throw TimeoutException('$operation timed out', config.operationTimeout); + } + } + + void _setConnectionState(WebSocketConnectionState next) { + if (_connectionState == next) { + return; + } + _connectionState = next; + _connectionStateController.add(next); + } + + void _setAuthenticated(bool next) { + if (_isAuthenticated == next) { + return; + } + _isAuthenticated = next; + _authStateController.add(next); + } + + String _jsErrorMessage(JSAny? error) { + if (error == null) { + return 'Unknown Convex error'; + } + try { + final object = error as JSObject; + final message = object.getProperty('message'.toJS)?.dartify(); + if (message is String && message.isNotEmpty) { + return message; + } + final dartValue = error.dartify(); + return dartValue?.toString() ?? 'Unknown Convex error'; + } catch (_) { + return 'Unknown Convex error'; + } + } +} + +class _WebSubscriptionHandle implements SubscriptionHandle { + _WebSubscriptionHandle({ + required JSFunction unsubscribe, + required JSFunction updateCallback, + required JSFunction errorCallback, + }) : _unsubscribe = unsubscribe, + _callbacks = [updateCallback, errorCallback]; + + JSFunction? _unsubscribe; + final List _callbacks; + + @override + void cancel() { + _unsubscribe?.callAsFunction(); + _unsubscribe = null; + _callbacks.clear(); + } +} + +class _WebAuthHandleWrapper implements AuthHandleWrapper { + _WebAuthHandleWrapper({ + required void Function() onDispose, + required JSFunction tokenCallback, + required JSFunction authCallback, + }) : _onDispose = onDispose, + _callbacks = [tokenCallback, authCallback]; + + void Function()? _onDispose; + final List _callbacks; + + @override + void dispose() { + _onDispose?.call(); + _onDispose = null; + _callbacks.clear(); + } +} diff --git a/lib/const/hive_boxes.dart b/lib/const/hive_boxes.dart index b1460824..17f2ba99 100644 --- a/lib/const/hive_boxes.dart +++ b/lib/const/hive_boxes.dart @@ -4,4 +4,5 @@ class HiveBoxNames { static const mapThemeProfilesBox = "map_theme_profiles_box"; static const appPreferencesBox = "app_preferences_box"; static const favoriteAgentsBox = "favorite_agents_box"; + static const strategyOutboxBox = "strategy_outbox_box"; } diff --git a/lib/const/shortcut_info.dart b/lib/const/shortcut_info.dart index c5d009e4..2f992e06 100644 --- a/lib/const/shortcut_info.dart +++ b/lib/const/shortcut_info.dart @@ -337,7 +337,8 @@ class ShortcutInfo { static final Map globalShortcuts = globalShortcutsFor(const {}); - // New map to disable global shortcuts when typing + // Fallback blockers for app shortcuts that native text editing does not own. + // TextEditingShortcutScope places DefaultTextEditingShortcuts inside these. static Map textEditingOverridesFor( Map customBindings, { TargetPlatform? platform, diff --git a/lib/main.dart b/lib/main.dart index ceff0bc0..26bae03e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,7 +3,7 @@ import 'dart:developer' as developer; import 'dart:ui' show PlatformDispatcher; import 'package:app_links/app_links.dart'; -import 'package:convex_flutter/convex_flutter.dart'; +import 'package:icarus/collab/convex_client.dart'; import 'package:custom_mouse_cursor/custom_mouse_cursor.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; @@ -139,6 +139,7 @@ Future main(List args) async { await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); await Hive.openBox(HiveBoxNames.appPreferencesBox); await Hive.openBox(HiveBoxNames.favoriteAgentsBox); + await Hive.openBox(HiveBoxNames.strategyOutboxBox); await MapThemeProfilesProvider.bootstrap(); diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 66ee601c..637350da 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:developer'; -import 'package:convex_flutter/convex_flutter.dart'; +import 'package:icarus/collab/convex_client.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/app_navigator.dart'; @@ -22,6 +22,15 @@ enum ConvexAuthStatus { final RegExp _convexCodeRegex = RegExp(r'"code"\s*:\s*"([A-Z_]+)"'); +const _sensitiveAuthKeys = { + 'access_token', + 'refresh_token', + 'provider_token', + 'provider_refresh_token', + 'code', + 'code_verifier', +}; + String? _extractConvexErrorCodeFromText(String text) { final match = _convexCodeRegex.firstMatch(text); final code = match?.group(1); @@ -53,15 +62,6 @@ bool isConvexUnauthenticatedError(Object error) { } String redactAuthUri(Uri uri) { - const sensitiveKeys = { - 'access_token', - 'refresh_token', - 'provider_token', - 'provider_refresh_token', - 'code', - 'code_verifier', - }; - String redactFragment(String fragment) { if (fragment.isEmpty) { return fragment; @@ -73,7 +73,7 @@ String redactAuthUri(Uri uri) { } return params.entries.map((entry) { - final value = sensitiveKeys.contains(entry.key.toLowerCase()) + final value = _sensitiveAuthKeys.contains(entry.key.toLowerCase()) ? '' : entry.value; return '${Uri.encodeQueryComponent(entry.key)}=' @@ -83,9 +83,10 @@ String redactAuthUri(Uri uri) { final queryParameters = {}; for (final entry in uri.queryParameters.entries) { - queryParameters[entry.key] = sensitiveKeys.contains(entry.key.toLowerCase()) - ? '' - : entry.value; + queryParameters[entry.key] = + _sensitiveAuthKeys.contains(entry.key.toLowerCase()) + ? '' + : entry.value; } return uri @@ -96,6 +97,35 @@ String redactAuthUri(Uri uri) { .toString(); } +String redactAuthDiagnosticText(Object value) { + var redacted = value.toString(); + + for (final key in _sensitiveAuthKeys) { + final escapedKey = RegExp.escape(key); + final keyValuePattern = RegExp( + "([\"']?$escapedKey[\"']?\\s*(?:=|:)\\s*)([\"']?)" + "([^&#,;\\s}\\]\"']+)([\"']?)", + caseSensitive: false, + ); + redacted = redacted.replaceAllMapped( + keyValuePattern, + (match) => '${match.group(1)}${match.group(2)}' + '${match.group(4)}', + ); + + final encodedKeyValuePattern = RegExp( + '($escapedKey%3D)(.*?)(?=%26|\\s|\$)', + caseSensitive: false, + ); + redacted = redacted.replaceAllMapped( + encodedKeyValuePattern, + (match) => '${match.group(1)}%3Credacted%3E', + ); + } + + return redacted; +} + class AppAuthState { const AppAuthState({ required this.isLoading, @@ -686,6 +716,10 @@ class AuthProvider extends Notifier { } Future signOut() async { + // Invalidate any setup that is currently awaiting a Convex auth handle + // before the Supabase auth-state event has a chance to arrive. Otherwise a + // stale setup can resume during sign-out and surface a false auth incident. + _advanceAuthGeneration(); state = state.copyWith( isLoading: true, isConvexUserReady: false, @@ -772,23 +806,27 @@ class AuthProvider extends Notifier { ); return true; } catch (error, stackTrace) { + final safeError = redactAuthDiagnosticText(error); + final safeStackTrace = StackTrace.fromString( + redactAuthDiagnosticText(stackTrace), + ); log( - 'Failed auth callback [$source]: $error', + 'Failed auth callback [$source]: $safeError', name: 'auth', - error: error, - stackTrace: stackTrace, + error: safeError, + stackTrace: safeStackTrace, ); AppErrorReporter.reportError( 'Failed auth callback [$source]: ${redactAuthUri(uri)}', source: 'auth', - error: error, - stackTrace: stackTrace, + error: safeError, + stackTrace: safeStackTrace, ); state = state.copyWith( isLoading: false, isConvexUserReady: false, convexAuthStatus: ConvexAuthStatus.incident, - errorMessage: 'Failed to complete login: $error', + errorMessage: 'Failed to complete login. Please try again.', ); return true; } diff --git a/lib/providers/collab/active_page_live_sync_models.dart b/lib/providers/collab/active_page_live_sync_models.dart index 5d81b8cd..c885b1bc 100644 --- a/lib/providers/collab/active_page_live_sync_models.dart +++ b/lib/providers/collab/active_page_live_sync_models.dart @@ -1,8 +1,19 @@ import 'package:icarus/collab/collab_models.dart'; -enum ActivePageOverlayEntityType { pageSettings, element, lineup } +enum ActivePageOverlayEntityType { + pageDescriptor, + pageContent, + element, + lineup +} -enum EntitySyncKeyKind { strategy, pageSettings, element, lineup } +enum EntitySyncKeyKind { + strategy, + pageDescriptor, + pageContent, + element, + lineup +} class EntitySyncKey { const EntitySyncKey._({ @@ -11,9 +22,16 @@ class EntitySyncKey { required this.entityId, }); - const EntitySyncKey.pageSettings(String pageId) + const EntitySyncKey.pageDescriptor(String pageId) : this._( - kind: EntitySyncKeyKind.pageSettings, + kind: EntitySyncKeyKind.pageDescriptor, + pageId: pageId, + entityId: null, + ); + + const EntitySyncKey.pageContent(String pageId) + : this._( + kind: EntitySyncKeyKind.pageContent, pageId: pageId, entityId: null, ); @@ -51,7 +69,13 @@ class EntitySyncKey { if (pageId == null) { return null; } - return EntitySyncKey.pageSettings(pageId); + return EntitySyncKey.pageDescriptor(pageId); + case StrategyOpEntityType.pageContent: + final pageId = op.entityPublicId ?? op.pagePublicId; + if (pageId == null) { + return null; + } + return EntitySyncKey.pageContent(pageId); case StrategyOpEntityType.element: if (op.pagePublicId == null || op.entityPublicId == null) { return null; @@ -72,8 +96,9 @@ class EntitySyncKey { ActivePageOverlayEntityType? get overlayType { return switch (kind) { EntitySyncKeyKind.strategy => null, - EntitySyncKeyKind.pageSettings => - ActivePageOverlayEntityType.pageSettings, + EntitySyncKeyKind.pageDescriptor => + ActivePageOverlayEntityType.pageDescriptor, + EntitySyncKeyKind.pageContent => ActivePageOverlayEntityType.pageContent, EntitySyncKeyKind.element => ActivePageOverlayEntityType.element, EntitySyncKeyKind.lineup => ActivePageOverlayEntityType.lineup, }; @@ -96,7 +121,8 @@ class EntitySyncKey { final encodedEntityId = _encodeEntityKeyPart(entityId ?? ''); return switch (kind) { EntitySyncKeyKind.strategy => 'strategy', - EntitySyncKeyKind.pageSettings => 'page:$encodedPageId:settings', + EntitySyncKeyKind.pageDescriptor => 'page:$encodedPageId:descriptor', + EntitySyncKeyKind.pageContent => 'page:$encodedPageId:content', EntitySyncKeyKind.element => 'element:$encodedPageId:$encodedEntityId', EntitySyncKeyKind.lineup => 'lineup:$encodedPageId:$encodedEntityId', }; diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart index 88806591..63dc57e9 100644 --- a/lib/providers/collab/active_page_live_sync_provider.dart +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -91,22 +91,28 @@ class ActivePageLiveSyncNotifier extends Notifier { } bool hasOverlayForPage(String pageId) { - return state.overlayByEntityKey.keys - .any((key) => key.pageId == pageId); + return state.overlayByEntityKey.keys.any((key) => key.pageId == pageId); } void recordAckBatch(List intents) { state = state.copyWith(lastAckBatch: intents); } - Map syncLocalPage({ + Map? syncLocalPage({ required String strategyPublicId, required String pageId, }) { setContext(strategyPublicId: strategyPublicId, activePageId: pageId); - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; - if (snapshot == null) { - return const {}; + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final remotePage = snapshot?.activePage; + if (snapshot == null || + snapshot.header.publicId != strategyPublicId || + remotePage == null || + remotePage.page.publicId != pageId) { + _debugLog( + 'sync.skip page=$pageId reason=missing_matching_remote_base', + ); + return null; } final queueState = ref.read(strategyOpQueueProvider); @@ -123,10 +129,8 @@ class ActivePageLiveSyncNotifier extends Notifier { final pageKeys = { ...remoteEntities.keys, ...localEntities.keys, - ...state.overlayByEntityKey.keys - .where((key) => key.pageId == pageId), - ...queueState.queuedByEntityKey.keys - .where((key) => key.pageId == pageId), + ...state.overlayByEntityKey.keys.where((key) => key.pageId == pageId), + ...queueState.queuedByEntityKey.keys.where((key) => key.pageId == pageId), ...queueState.inFlightByEntityKey.keys .where((key) => key.pageId == pageId), }; @@ -237,8 +241,10 @@ class ActivePageLiveSyncNotifier extends Notifier { required String pageId, }) { setContext(strategyPublicId: strategyPublicId, activePageId: pageId); - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; - if (snapshot == null) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || + snapshot.header.publicId != strategyPublicId || + snapshot.activePage?.page.publicId != pageId) { return null; } @@ -263,14 +269,15 @@ class ActivePageLiveSyncNotifier extends Notifier { for (final lineup in (snapshot.lineupsByPage[page.publicId] ?? const [])) if (!lineup.deleted) - EntitySyncKey.lineup(page.publicId, lineup.publicId): ProjectedPageLineup( + EntitySyncKey.lineup(page.publicId, lineup.publicId): + ProjectedPageLineup( publicId: lineup.publicId, payload: lineup.payload, sortIndex: lineup.sortIndex, ), }; - var projectedSettingsPayload = page.settings; + var projectedSettingsPayload = snapshot.activePage?.content.settings; var projectedIsAttack = page.isAttack; final pageOverlays = state.overlayByEntityKey.entries.where( @@ -279,18 +286,23 @@ class ActivePageLiveSyncNotifier extends Notifier { for (final entry in pageOverlays) { final overlay = entry.value; switch (overlay.entityType) { - case ActivePageOverlayEntityType.pageSettings: + case ActivePageOverlayEntityType.pageDescriptor: if (overlay.desiredPayload == null) { continue; } final decoded = _decodeObject(overlay.desiredPayload!); - projectedSettingsPayload = - cloudObjectPayloadOrNull(decoded['settings']); final isAttack = decoded['isAttack']; if (isAttack is bool) { projectedIsAttack = isAttack; } continue; + case ActivePageOverlayEntityType.pageContent: + if (overlay.desiredPayload != null) { + final decoded = _decodeObject(overlay.desiredPayload!); + projectedSettingsPayload = + cloudObjectPayloadOrNull(decoded['settings']); + } + continue; case ActivePageOverlayEntityType.element: if (overlay.deletion) { remoteElements.remove(entry.key); @@ -362,7 +374,7 @@ class ActivePageLiveSyncNotifier extends Notifier { } Map _normalizedRemoteEntities( - RemoteStrategySnapshot snapshot, + RemoteEditorSnapshot snapshot, String pageId, ) { final page = _remotePageById(snapshot: snapshot, pageId: pageId); @@ -370,25 +382,34 @@ class ActivePageLiveSyncNotifier extends Notifier { return const {}; } + final pageSnapshot = snapshot.activePage; + if (pageSnapshot == null || pageSnapshot.page.publicId != page.publicId) { + return const {}; + } + final entities = { - EntitySyncKey.pageSettings(page.publicId): _NormalizedEntity( - key: EntitySyncKey.pageSettings(page.publicId), - overlayEntityType: ActivePageOverlayEntityType.pageSettings, - payload: _pagePayload( - settingsPayload: page.settings, - isAttack: page.isAttack, - ), + EntitySyncKey.pageDescriptor(page.publicId): _NormalizedEntity( + key: EntitySyncKey.pageDescriptor(page.publicId), + overlayEntityType: ActivePageOverlayEntityType.pageDescriptor, + payload: {'isAttack': page.isAttack}, sortIndex: null, revision: page.revision, deleted: false, ), + EntitySyncKey.pageContent(page.publicId): _NormalizedEntity( + key: EntitySyncKey.pageContent(page.publicId), + overlayEntityType: ActivePageOverlayEntityType.pageContent, + payload: { + 'settings': pageSnapshot.content.settings, + }, + sortIndex: null, + revision: pageSnapshot.content.revision, + deleted: false, + ), }; for (final element in (snapshot.elementsByPage[page.publicId] ?? const [])) { - if (element.deleted) { - continue; - } final key = EntitySyncKey.element(page.publicId, element.publicId); entities[key] = _NormalizedEntity( key: key, @@ -396,15 +417,12 @@ class ActivePageLiveSyncNotifier extends Notifier { payload: element.payload, sortIndex: element.sortIndex, revision: element.revision, - deleted: false, + deleted: element.deleted, ); } for (final lineup in (snapshot.lineupsByPage[page.publicId] ?? const [])) { - if (lineup.deleted) { - continue; - } final key = EntitySyncKey.lineup(page.publicId, lineup.publicId); entities[key] = _NormalizedEntity( key: key, @@ -412,7 +430,7 @@ class ActivePageLiveSyncNotifier extends Notifier { payload: lineup.payload, sortIndex: lineup.sortIndex, revision: lineup.revision, - deleted: false, + deleted: lineup.deleted, ); } @@ -423,14 +441,24 @@ class ActivePageLiveSyncNotifier extends Notifier { String pageId) { final entities = {}; - final pageKey = EntitySyncKey.pageSettings(pageId); - entities[pageKey] = _NormalizedEntity( - key: pageKey, - overlayEntityType: ActivePageOverlayEntityType.pageSettings, - payload: _pagePayload( - settingsPayload: ref.read(strategySettingsProvider).toJson(), - isAttack: ref.read(mapProvider).isAttack, - ), + final descriptorKey = EntitySyncKey.pageDescriptor(pageId); + entities[descriptorKey] = _NormalizedEntity( + key: descriptorKey, + overlayEntityType: ActivePageOverlayEntityType.pageDescriptor, + payload: { + 'isAttack': ref.read(mapProvider).isAttack, + }, + sortIndex: null, + revision: 0, + deleted: false, + ); + final contentKey = EntitySyncKey.pageContent(pageId); + entities[contentKey] = _NormalizedEntity( + key: contentKey, + overlayEntityType: ActivePageOverlayEntityType.pageContent, + payload: { + 'settings': ref.read(strategySettingsProvider).toJson(), + }, sortIndex: null, revision: 0, deleted: false, @@ -443,8 +471,8 @@ class ActivePageLiveSyncNotifier extends Notifier { entities[key] = _NormalizedEntity( key: key, overlayEntityType: ActivePageOverlayEntityType.element, - payload: - cloudElementPayload(kind: envelope.kind.name, data: envelope.payload), + payload: cloudElementPayload( + kind: envelope.kind.name, data: envelope.payload), sortIndex: index, revision: 0, deleted: false, @@ -573,7 +601,7 @@ class ActivePageLiveSyncNotifier extends Notifier { }) { final entityId = overlay.entityKey.entityId; switch (overlay.entityType) { - case ActivePageOverlayEntityType.pageSettings: + case ActivePageOverlayEntityType.pageDescriptor: return StrategyOp( opId: const Uuid().v4(), kind: StrategyOpKind.patch, @@ -582,6 +610,15 @@ class ActivePageLiveSyncNotifier extends Notifier { payload: overlay.desiredPayload, expectedRevision: remote?.revision ?? overlay.baseRevision, ); + case ActivePageOverlayEntityType.pageContent: + return StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.pageContent, + entityPublicId: pageId, + payload: overlay.desiredPayload, + expectedRevision: remote?.revision ?? overlay.baseRevision, + ); case ActivePageOverlayEntityType.element: if (entityId == null) { return null; @@ -598,13 +635,15 @@ class ActivePageLiveSyncNotifier extends Notifier { } return StrategyOp( opId: const Uuid().v4(), - kind: remote == null ? StrategyOpKind.add : StrategyOpKind.patch, + kind: remote == null || remote.deleted + ? StrategyOpKind.add + : StrategyOpKind.patch, entityType: StrategyOpEntityType.element, entityPublicId: entityId, pagePublicId: pageId, payload: overlay.desiredPayload, sortIndex: overlay.desiredSortIndex, - expectedRevision: remote == null ? null : (remote.revision), + expectedRevision: remote?.revision, ); case ActivePageOverlayEntityType.lineup: if (entityId == null) { @@ -622,7 +661,9 @@ class ActivePageLiveSyncNotifier extends Notifier { } return StrategyOp( opId: const Uuid().v4(), - kind: remote == null ? StrategyOpKind.add : StrategyOpKind.patch, + kind: remote == null || remote.deleted + ? StrategyOpKind.add + : StrategyOpKind.patch, entityType: StrategyOpEntityType.lineup, entityPublicId: entityId, pagePublicId: pageId, @@ -638,9 +679,9 @@ class ActivePageLiveSyncNotifier extends Notifier { _NormalizedEntity? remote, ) { if (overlay.deletion) { - return remote == null; + return remote == null || remote.deleted; } - if (remote == null) { + if (remote == null || remote.deleted) { return false; } return _payloadsEquivalent(overlay.desiredPayload, remote.payload) && @@ -654,7 +695,10 @@ class ActivePageLiveSyncNotifier extends Notifier { if (identical(local, remote)) { return true; } - if (local == null || remote == null) { + if (local == null) { + return remote?.deleted ?? false; + } + if (remote == null) { return false; } return local.deleted == remote.deleted && @@ -667,16 +711,6 @@ class ActivePageLiveSyncNotifier extends Notifier { return cloudJsonEquivalent(left, right); } - Map _pagePayload({ - required Map? settingsPayload, - required bool isAttack, - }) { - return { - 'settings': settingsPayload, - 'isAttack': isAttack, - }; - } - void _debugLog(String message) { assert(() { log(message, name: 'active_page_live_sync'); @@ -685,7 +719,7 @@ class ActivePageLiveSyncNotifier extends Notifier { } RemotePage? _remotePageById({ - required RemoteStrategySnapshot snapshot, + required RemoteEditorSnapshot snapshot, required String pageId, }) { for (final page in snapshot.pages) { diff --git a/lib/providers/collab/cloud_media_upload_queue_provider.dart b/lib/providers/collab/cloud_media_upload_queue_provider.dart index 0ace9bd3..0b9794ec 100644 --- a/lib/providers/collab/cloud_media_upload_queue_provider.dart +++ b/lib/providers/collab/cloud_media_upload_queue_provider.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; -import 'package:convex_flutter/convex_flutter.dart'; +import 'package:icarus/collab/convex_client.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; diff --git a/lib/providers/collab/cloud_migration_provider.dart b/lib/providers/collab/cloud_migration_provider.dart index 3899c8cb..0a47261d 100644 --- a/lib/providers/collab/cloud_migration_provider.dart +++ b/lib/providers/collab/cloud_migration_provider.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:developer'; -import 'package:convex_flutter/convex_flutter.dart'; +import 'package:icarus/collab/convex_client.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/collab/collab_models.dart'; @@ -19,6 +19,132 @@ import 'package:uuid/uuid.dart'; final cloudMigrationProvider = NotifierProvider(CloudMigrationNotifier.new); +final cloudMigrationApiProvider = Provider((ref) { + return _DefaultCloudMigrationApi( + ref.read(convexStrategyRepositoryProvider), + ); +}); + +abstract class CloudMigrationApi { + Future createFolder({ + required String publicId, + required String name, + String? parentFolderPublicId, + int? iconId, + }); + + Future createStrategyWithInitialPage({ + required String publicId, + required String name, + required String mapData, + required String initialPagePublicId, + required String initialPageName, + required bool initialPageIsAttack, + required Map initialPageSettings, + String? folderPublicId, + String? themeProfileId, + Map? themeOverridePalette, + }); + + Future addPage({ + required String strategyPublicId, + required String pagePublicId, + required String name, + required int sortIndex, + required bool isAttack, + required Map settings, + required int expectedRevision, + }); + + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }); +} + +class _DefaultCloudMigrationApi implements CloudMigrationApi { + const _DefaultCloudMigrationApi(this._repository); + + final ConvexStrategyRepository _repository; + + @override + Future createFolder({ + required String publicId, + required String name, + String? parentFolderPublicId, + int? iconId, + }) { + return _repository.createFolder( + publicId: publicId, + name: name, + parentFolderPublicId: parentFolderPublicId, + iconId: iconId, + ); + } + + @override + Future createStrategyWithInitialPage({ + required String publicId, + required String name, + required String mapData, + required String initialPagePublicId, + required String initialPageName, + required bool initialPageIsAttack, + required Map initialPageSettings, + String? folderPublicId, + String? themeProfileId, + Map? themeOverridePalette, + }) { + return _repository.createStrategyWithInitialPage( + publicId: publicId, + name: name, + mapData: mapData, + initialPagePublicId: initialPagePublicId, + initialPageName: initialPageName, + initialPageIsAttack: initialPageIsAttack, + initialPageSettings: initialPageSettings, + folderPublicId: folderPublicId, + themeProfileId: themeProfileId, + themeOverridePalette: themeOverridePalette, + ); + } + + @override + Future addPage({ + required String strategyPublicId, + required String pagePublicId, + required String name, + required int sortIndex, + required bool isAttack, + required Map settings, + required int expectedRevision, + }) async { + await ConvexClient.instance.mutation(name: 'pages:add', args: { + 'strategyPublicId': strategyPublicId, + 'pagePublicId': pagePublicId, + 'name': name, + 'sortIndex': sortIndex, + 'isAttack': isAttack, + 'settings': settings, + 'expectedRevision': expectedRevision, + }); + } + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) { + return _repository.applyBatch( + strategyPublicId: strategyPublicId, + clientId: clientId, + ops: ops, + ); + } +} + class CloudMigrationNotifier extends Notifier { @override bool build() => false; @@ -27,21 +153,41 @@ class CloudMigrationNotifier extends Notifier { if (state) return; if (!ref.read(isCloudCollabEnabledProvider)) return; - final repo = ref.read(convexStrategyRepositoryProvider); + final api = ref.read(cloudMigrationApiProvider); final folders = Hive.box(HiveBoxNames.foldersBox).values.toList(); final strategies = Hive.box(HiveBoxNames.strategiesBox).values.toList(); + var migrationSucceeded = true; + + Future recordFailure({ + required String source, + required Object error, + required StackTrace stackTrace, + }) async { + migrationSucceeded = false; + log( + 'Cloud migration write failed at $source: $error', + name: 'cloud_migration', + error: error, + stackTrace: stackTrace, + ); + await _maybeReportCloudUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ); + } for (final folder in folders) { try { - await repo.createFolder( + await api.createFolder( publicId: folder.id, name: folder.name, parentFolderPublicId: folder.parentID, iconId: folder.iconId, ); } catch (error, stackTrace) { - await _maybeReportCloudUnauthenticated( + await recordFailure( source: 'cloud_migration:create_folder', error: error, stackTrace: stackTrace, @@ -55,7 +201,7 @@ class CloudMigrationNotifier extends Notifier { final firstPage = pages.isNotEmpty ? pages.first : null; final fallbackPageId = const Uuid().v4(); try { - await repo.createStrategyWithInitialPage( + await api.createStrategyWithInitialPage( publicId: strategy.id, name: strategy.name, mapData: Maps.mapNames[strategy.mapData] ?? 'ascent', @@ -70,7 +216,7 @@ class CloudMigrationNotifier extends Notifier { themeOverridePalette: strategy.themeOverridePalette?.toJson(), ); } catch (error, stackTrace) { - await _maybeReportCloudUnauthenticated( + await recordFailure( source: 'cloud_migration:create_strategy', error: error, stackTrace: stackTrace, @@ -92,16 +238,17 @@ class CloudMigrationNotifier extends Notifier { continue; } try { - await ConvexClient.instance.mutation(name: 'pages:add', args: { - 'strategyPublicId': strategy.id, - 'pagePublicId': page.id, - 'name': page.name, - 'sortIndex': page.sortIndex, - 'isAttack': page.isAttack, - 'settings': page.settings.toJson(), - }); + await api.addPage( + strategyPublicId: strategy.id, + pagePublicId: page.id, + name: page.name, + sortIndex: page.sortIndex, + isAttack: page.isAttack, + settings: page.settings.toJson(), + expectedRevision: i - 1, + ); } catch (error, stackTrace) { - await _maybeReportCloudUnauthenticated( + await recordFailure( source: 'cloud_migration:add_page', error: error, stackTrace: stackTrace, @@ -118,23 +265,37 @@ class CloudMigrationNotifier extends Notifier { if (allOps.isNotEmpty) { try { - await repo.applyBatch( + final acknowledgements = await api.applyBatch( strategyPublicId: strategy.id, clientId: const Uuid().v4(), ops: allOps, ); + final rejected = acknowledgements.where((ack) => !ack.isAck).toList(); + if (acknowledgements.length != allOps.length || rejected.isNotEmpty) { + final rejectionReasons = rejected + .map((ack) => ack.reason ?? ack.status) + .toSet() + .join(', '); + await recordFailure( + source: 'cloud_migration:apply_batch', + error: StateError( + 'Cloud migration batch was not fully acknowledged' + '${rejectionReasons.isEmpty ? '' : ': $rejectionReasons'}', + ), + stackTrace: StackTrace.current, + ); + } } catch (error, stackTrace) { - await _maybeReportCloudUnauthenticated( + await recordFailure( source: 'cloud_migration:apply_batch', error: error, stackTrace: stackTrace, ); - log('Cloud migration ops failed for ${strategy.id}: $error'); } } } - state = true; + state = migrationSucceeded; } Future _maybeReportCloudUnauthenticated({ diff --git a/lib/providers/collab/convex_connection_provider.dart b/lib/providers/collab/convex_connection_provider.dart index 6fc07715..7b05b28f 100644 --- a/lib/providers/collab/convex_connection_provider.dart +++ b/lib/providers/collab/convex_connection_provider.dart @@ -1,4 +1,4 @@ -import 'package:convex_flutter/convex_flutter.dart'; +import 'package:icarus/collab/convex_client.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Reactive view of the Convex WebSocket connection, seeded with the current diff --git a/lib/providers/collab/remote_strategy_snapshot_provider.dart b/lib/providers/collab/remote_strategy_snapshot_provider.dart index 5ad35f58..611bd870 100644 --- a/lib/providers/collab/remote_strategy_snapshot_provider.dart +++ b/lib/providers/collab/remote_strategy_snapshot_provider.dart @@ -9,205 +9,288 @@ import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/image_provider.dart'; -final remoteStrategySnapshotProvider = AsyncNotifierProvider< - RemoteStrategySnapshotNotifier, RemoteStrategySnapshot?>( - RemoteStrategySnapshotNotifier.new, +final remoteEditorSnapshotProvider = + AsyncNotifierProvider( + RemoteEditorSnapshotNotifier.new, ); -class RemoteStrategySnapshotNotifier - extends AsyncNotifier { +/// Owns the editor's bounded live read set: one shell and one active page. +class RemoteEditorSnapshotNotifier + extends AsyncNotifier { String? _activeStrategyPublicId; - StreamSubscription? _snapshotSubscription; + String? _activePagePublicId; + StreamSubscription? _shellSubscription; + StreamSubscription? _pageSubscription; Timer? _refreshDebounce; + int _pageEpoch = 0; Map? _lastReconciledAssetsById; @override - Future build() async { + Future build() async { ref.onDispose(_disposeSubscriptions); return null; } String? get activeStrategyPublicId => _activeStrategyPublicId; + String? get activePagePublicId => _activePagePublicId; - Future openStrategy(String strategyPublicId) async { + Future openStrategy( + String strategyPublicId, { + String? activePagePublicId, + }) async { + _disposeSubscriptions(); _activeStrategyPublicId = strategyPublicId; + _activePagePublicId = activePagePublicId; _lastReconciledAssetsById = null; - ref - .read(strategyOpQueueProvider.notifier) - .setActiveStrategy(strategyPublicId); + ref.read(strategyOpQueueProvider.notifier).setActiveStrategy( + strategyPublicId, + accountId: ref.read(authProvider).user?.id, + ); state = const AsyncLoading(); await _refreshFromServer(); - await _startSubscriptions(strategyPublicId); + await _startShellSubscription(strategyPublicId); + final pageId = _activePagePublicId; + if (pageId != null) { + await _startPageSubscription(strategyPublicId, pageId); + } } - Future refresh() async { - if (_activeStrategyPublicId == null) { - return; + Future setActivePage(String? pagePublicId) async { + final strategyPublicId = _activeStrategyPublicId; + if (strategyPublicId == null || pagePublicId == _activePagePublicId) { + return state.valueOrNull?.activePage; + } + + _activePagePublicId = pagePublicId; + final epoch = ++_pageEpoch; + await _pageSubscription?.cancel(); + _pageSubscription = null; + + final current = state.valueOrNull; + if (current != null) { + state = AsyncData(current.copyWith(clearActivePage: true)); + } + if (pagePublicId == null) return null; + + try { + final page = + await ref.read(convexStrategyRepositoryProvider).fetchPageSnapshot( + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + ); + if (epoch != _pageEpoch || pagePublicId != _activePagePublicId) { + return null; + } + _replacePage(page); + await _startPageSubscription(strategyPublicId, pagePublicId); + return page; + } catch (error, stackTrace) { + _handleReadError( + source: 'remote_editor:page_refresh', + error: error, + stackTrace: stackTrace, + ); + return null; } - await _refreshFromServer(); + } + + Future refresh() async { + if (_activeStrategyPublicId != null) await _refreshFromServer(); } void clear() { _activeStrategyPublicId = null; + _activePagePublicId = null; _lastReconciledAssetsById = null; _disposeSubscriptions(); - ref.read(strategyOpQueueProvider.notifier).setActiveStrategy(null); + ref.read(strategyOpQueueProvider.notifier).setActiveStrategy( + null, + accountId: ref.read(authProvider).user?.id, + ); state = const AsyncData(null); } Future _refreshFromServer() async { final strategyPublicId = _activeStrategyPublicId; - if (strategyPublicId == null) { - return; - } - - final auth = ref.read(authProvider); - if (auth.hasActiveAuthIncident) { + if (strategyPublicId == null) return; + if (ref.read(authProvider).hasActiveAuthIncident) { state = const AsyncData(null); return; } try { - final snapshot = await ref - .read(convexStrategyRepositoryProvider) - .fetchSnapshot(strategyPublicId); - state = AsyncData(snapshot); - } catch (error, stackTrace) { - if (isConvexUnauthenticatedError(error)) { - unawaited( - ref.read(authProvider.notifier).reportConvexUnauthenticated( - source: 'remote_snapshot:refresh', - error: error, - stackTrace: stackTrace, - ), - ); - state = const AsyncData(null); - return; + final repository = ref.read(convexStrategyRepositoryProvider); + final shell = await repository.fetchShell(strategyPublicId); + var pageId = _activePagePublicId; + if (pageId == null || + !shell.pages.any((page) => page.publicId == pageId)) { + pageId = shell.pages.firstOrNull?.publicId; + _activePagePublicId = pageId; } - - log('Failed to refresh remote snapshot: $error', - error: error, stackTrace: stackTrace); - state = AsyncError(error, stackTrace); + final page = pageId == null + ? null + : await repository.fetchPageSnapshot( + strategyPublicId: strategyPublicId, + pagePublicId: pageId, + ); + state = AsyncData(RemoteEditorSnapshot(shell: shell, activePage: page)); + if (page != null) _reconcilePageMedia(page); + } catch (error, stackTrace) { + _handleReadError( + source: 'remote_editor:refresh', + error: error, + stackTrace: stackTrace, + ); } } - Future _startSubscriptions(String strategyPublicId) async { - _disposeSubscriptions(); - final repository = ref.read(convexStrategyRepositoryProvider); - - _snapshotSubscription = repository.watchSnapshot(strategyPublicId).listen( - (snapshot) { - _replaceSnapshot(snapshot); - if (_shouldReconcilePageMedia(snapshot.assetsById)) { - unawaited( - ref.read(cloudMediaUploadQueueProvider.notifier).reconcilePageMedia( - strategyPublicId: strategyPublicId, - placedImages: ref.read(placedImageProvider).images, - assetsById: snapshot.assetsById, - ), - ); + Future _startShellSubscription(String strategyPublicId) async { + await _shellSubscription?.cancel(); + _shellSubscription = ref + .read(convexStrategyRepositoryProvider) + .watchShell(strategyPublicId) + .listen( + (shell) { + if (_activeStrategyPublicId != strategyPublicId || + ref.read(authProvider).hasActiveAuthIncident) return; + final current = state.valueOrNull; + state = AsyncData(RemoteEditorSnapshot( + shell: shell, + activePage: current?.activePage, + )); + final activePageId = _activePagePublicId; + if (activePageId != null && + !shell.pages.any((page) => page.publicId == activePageId)) { + unawaited(setActivePage(shell.pages.firstOrNull?.publicId)); } }, - onError: (error, stackTrace) => _handleSubscriptionError( - source: 'remote_snapshot:snapshot_subscription', + onError: (Object error, StackTrace stackTrace) => + _handleSubscriptionError( + source: 'remote_editor:shell_subscription', error: error, stackTrace: stackTrace, ), ); } - void _replaceSnapshot(RemoteStrategySnapshot snapshot) { - if (_activeStrategyPublicId == null) { - return; - } - if (ref.read(authProvider).hasActiveAuthIncident) { - return; - } - - state = AsyncData(snapshot); + Future _startPageSubscription( + String strategyPublicId, + String pagePublicId, + ) async { + await _pageSubscription?.cancel(); + final epoch = ++_pageEpoch; + _pageSubscription = ref + .read(convexStrategyRepositoryProvider) + .watchPageSnapshot( + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + ) + .listen( + (page) { + if (epoch != _pageEpoch || + _activeStrategyPublicId != strategyPublicId || + _activePagePublicId != pagePublicId || + ref.read(authProvider).hasActiveAuthIncident) return; + _replacePage(page); + }, + onError: (Object error, StackTrace stackTrace) => + _handleSubscriptionError( + source: 'remote_editor:page_subscription', + error: error, + stackTrace: stackTrace, + ), + ); } - bool _shouldReconcilePageMedia( - Map nextAssetsById, - ) { - final previous = _lastReconciledAssetsById; - if (previous != null && _sameReconcileAssetSet(previous, nextAssetsById)) { - return false; - } - - _lastReconciledAssetsById = - Map.unmodifiable(nextAssetsById); - return true; + void _replacePage(RemotePageSnapshot page) { + final current = state.valueOrNull; + if (current == null || page.page.publicId != _activePagePublicId) return; + state = AsyncData(current.copyWith(activePage: page)); + _reconcilePageMedia(page); } - bool _sameReconcileAssetSet( - Map previous, - Map next, - ) { - if (previous.length != next.length) { - return false; - } + void _reconcilePageMedia(RemotePageSnapshot page) { + if (!_shouldReconcilePageMedia(page.assetsById)) return; + final strategyPublicId = _activeStrategyPublicId; + if (strategyPublicId == null) return; + unawaited( + ref.read(cloudMediaUploadQueueProvider.notifier).reconcilePageMedia( + strategyPublicId: strategyPublicId, + placedImages: ref.read(placedImageProvider).images, + assetsById: page.assetsById, + ), + ); + } - for (final entry in next.entries) { - final previousAsset = previous[entry.key]; - final nextAsset = entry.value; - if (previousAsset == null || - previousAsset.publicId != nextAsset.publicId || - previousAsset.url != nextAsset.url || - previousAsset.uploadStatus != nextAsset.uploadStatus) { - return false; + bool _shouldReconcilePageMedia(Map next) { + final previous = _lastReconciledAssetsById; + if (previous != null && previous.length == next.length) { + var same = true; + for (final entry in next.entries) { + final old = previous[entry.key]; + if (old == null || + old.publicId != entry.value.publicId || + old.url != entry.value.url || + old.uploadStatus != entry.value.uploadStatus) { + same = false; + break; + } } + if (same) return false; } + _lastReconciledAssetsById = Map.unmodifiable(next); return true; } - void _handleSubscriptionError({ + void _handleReadError({ required String source, required Object error, - StackTrace? stackTrace, + required StackTrace stackTrace, }) { - final message = error.toString(); - if (isConvexUnauthenticatedMessage(message)) { - unawaited( - ref.read(authProvider.notifier).reportConvexUnauthenticated( - source: source, - error: error, - stackTrace: stackTrace, - ), - ); + if (isConvexUnauthenticatedError(error)) { + unawaited(ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + )); + state = const AsyncData(null); return; } - - log( - 'Remote snapshot subscription failed: $message', - name: 'remote_snapshot', - error: error, - stackTrace: stackTrace, - ); - _scheduleRefresh(); + log('Remote editor read failed: $error', + name: 'remote_editor', error: error, stackTrace: stackTrace); + state = AsyncError(error, stackTrace); } - void _scheduleRefresh() { - if (_activeStrategyPublicId == null) { - return; - } - - if (ref.read(authProvider).hasActiveAuthIncident) { + void _handleSubscriptionError({ + required String source, + required Object error, + StackTrace? stackTrace, + }) { + if (isConvexUnauthenticatedMessage(error.toString())) { + unawaited(ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + )); return; } - + log('Remote editor subscription failed: $error', + name: 'remote_editor', error: error, stackTrace: stackTrace); _refreshDebounce?.cancel(); - _refreshDebounce = Timer(const Duration(milliseconds: 120), () async { - await _refreshFromServer(); - }); + _refreshDebounce = Timer( + const Duration(milliseconds: 120), + () => unawaited(_refreshFromServer()), + ); } void _disposeSubscriptions() { _refreshDebounce?.cancel(); _refreshDebounce = null; - - unawaited(_snapshotSubscription?.cancel()); - _snapshotSubscription = null; + _pageEpoch += 1; + unawaited(_shellSubscription?.cancel()); + unawaited(_pageSubscription?.cancel()); + _shellSubscription = null; + _pageSubscription = null; } } diff --git a/lib/providers/collab/strategy_capabilities_provider.dart b/lib/providers/collab/strategy_capabilities_provider.dart index 50ee427b..5e45c2fe 100644 --- a/lib/providers/collab/strategy_capabilities_provider.dart +++ b/lib/providers/collab/strategy_capabilities_provider.dart @@ -83,14 +83,13 @@ final currentStrategyCapabilitiesProvider = !ref.watch(isCloudCollabEnabledProvider)) { return StrategyCapabilities.fullAccess(); } - final role = - ref.watch(remoteStrategySnapshotProvider).valueOrNull?.header.role; + final role = ref.watch(remoteEditorSnapshotProvider).valueOrNull?.header.role; return StrategyCapabilities.fromCloudRole(role); }); /// Last non-null cloud role reported for the currently open strategy. /// -/// [remoteStrategySnapshotProvider] transiently loses its value during +/// [remoteEditorSnapshotProvider] transiently loses its value during /// reloads, refresh errors, and auth incidents, so role-dependent UI (like /// the editor's "View only" chip) must not read `valueOrNull` directly or it /// flickers off mid-session. This provider remembers the last role seen for @@ -108,13 +107,13 @@ class LastKnownCloudRoleNotifier extends Notifier { // strategy is opened. ref.watch(strategyProvider.select((value) => value.strategyId)); - ref.listen(remoteStrategySnapshotProvider, (previous, next) { + ref.listen(remoteEditorSnapshotProvider, (previous, next) { final role = next.valueOrNull?.header.role; if (role != null) { state = role; } }); - return ref.read(remoteStrategySnapshotProvider).valueOrNull?.header.role; + return ref.read(remoteEditorSnapshotProvider).valueOrNull?.header.role; } } diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index 1186e32f..098abd3e 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'dart:developer'; import 'dart:math' as math; -import 'package:convex_flutter/convex_flutter.dart'; -import 'package:flutter/foundation.dart'; +import 'package:icarus/collab/convex_client.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/canonical_json.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/cloud_collab_provider.dart'; @@ -15,10 +15,15 @@ import 'package:uuid/uuid.dart'; class StrategyOpQueueState { const StrategyOpQueueState({ + this.accountId, this.strategyPublicId, this.clientId, this.queuedByEntityKey = const {}, this.inFlightByEntityKey = const {}, + this.pausedByEntityKey = const {}, + this.attentionByEntityKey = const {}, + this.loadIssues = const [], + this.durableLoaded = false, this.isFlushing = false, this.lastError, this.lastFlushAt, @@ -26,26 +31,38 @@ class StrategyOpQueueState { this.lastAckBatch = const [], }); + final String? accountId; final String? strategyPublicId; final String? clientId; final Map queuedByEntityKey; final Map inFlightByEntityKey; + final Map pausedByEntityKey; + final Map attentionByEntityKey; + final List loadIssues; + final bool durableLoaded; final bool isFlushing; final String? lastError; final DateTime? lastFlushAt; final List lastAcks; final List lastAckBatch; - List get pending => [ + bool get needsAttention => + loadIssues.isNotEmpty || + pausedByEntityKey.isNotEmpty || + attentionByEntityKey.isNotEmpty; + + List get pending => [ ...queuedByEntityKey.values.map((intent) => intent.pending), ...inFlightByEntityKey.values.map((intent) => intent.pending), + ...pausedByEntityKey.values.map((intent) => intent.pending), + ...attentionByEntityKey.values.map((intent) => intent.pending), ]; StrategyOpQueueState copyWith({ - String? strategyPublicId, - String? clientId, Map? queuedByEntityKey, Map? inFlightByEntityKey, + Map? pausedByEntityKey, + Map? attentionByEntityKey, bool? isFlushing, String? lastError, bool clearError = false, @@ -54,10 +71,15 @@ class StrategyOpQueueState { List? lastAckBatch, }) { return StrategyOpQueueState( - strategyPublicId: strategyPublicId ?? this.strategyPublicId, - clientId: clientId ?? this.clientId, + accountId: accountId, + strategyPublicId: strategyPublicId, + clientId: clientId, queuedByEntityKey: queuedByEntityKey ?? this.queuedByEntityKey, inFlightByEntityKey: inFlightByEntityKey ?? this.inFlightByEntityKey, + pausedByEntityKey: pausedByEntityKey ?? this.pausedByEntityKey, + attentionByEntityKey: attentionByEntityKey ?? this.attentionByEntityKey, + loadIssues: loadIssues, + durableLoaded: durableLoaded, isFlushing: isFlushing ?? this.isFlushing, lastError: clearError ? null : (lastError ?? this.lastError), lastFlushAt: lastFlushAt ?? this.lastFlushAt, @@ -83,329 +105,463 @@ class StrategyOpQueueNotifier extends Notifier { Timer? _debounceTimer; Timer? _retryTimer; int _offlineRetryCount = 0; + late DurableStrategyOutboxStore _store; + late Map _recordsByStorageKey; + Future _writeTail = Future.value(); ConvexStrategyRepository get _repo => ref.read(convexStrategyRepositoryProvider); @override StrategyOpQueueState build() { + _store = ref.read(durableStrategyOutboxStoreProvider); + final loaded = _store.load(); + _recordsByStorageKey = { + for (final record in loaded.records) record.storageKey: record, + }; ref.onDispose(() { _retryTimer?.cancel(); _debounceTimer?.cancel(); }); - return StrategyOpQueueState(clientId: const Uuid().v4()); + return StrategyOpQueueState( + clientId: const Uuid().v4(), + loadIssues: loaded.issues, + durableLoaded: true, + lastError: loaded.issues.isEmpty + ? null + : 'The cloud outbox contains unreadable saved work.', + ); } - void setActiveStrategy(String? strategyPublicId) { - if (state.strategyPublicId == strategyPublicId) { - return; - } + void setActiveStrategy( + String? strategyPublicId, { + required String? accountId, + }) { + if (state.strategyPublicId == strategyPublicId && + state.accountId == accountId) return; _debounceTimer?.cancel(); _retryTimer?.cancel(); _offlineRetryCount = 0; - state = state.copyWith( + final matching = accountId == null || strategyPublicId == null + ? const [] + : _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId) + .toList(growable: false); + final queued = {}; + final paused = {}; + final attention = {}; + for (final record in matching) { + final intent = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + switch (record.status) { + case DurableOutboxStatus.queued: + case DurableOutboxStatus.inFlight: + // An interrupted request is replayed with its original op/client id. + queued[record.entityKey] = intent; + case DurableOutboxStatus.paused: + paused[record.entityKey] = intent; + case DurableOutboxStatus.attention: + attention[record.entityKey] = intent; + } + } + final clientId = + matching.firstOrNull?.pending.clientId ?? const Uuid().v4(); + state = StrategyOpQueueState( + accountId: accountId, strategyPublicId: strategyPublicId, - clientId: const Uuid().v4(), - queuedByEntityKey: const {}, - inFlightByEntityKey: const {}, - lastAcks: const [], - lastAckBatch: const [], - clearError: true, + clientId: clientId, + queuedByEntityKey: queued, + pausedByEntityKey: paused, + attentionByEntityKey: attention, + loadIssues: state.loadIssues, + durableLoaded: true, + lastError: _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: paused, + attention: attention, + ), ); + if (queued.isNotEmpty) _scheduleFlush(flushImmediately: true); } - void enqueue(StrategyOp op, {bool flushImmediately = false}) { + Future enqueue( + StrategyOp op, { + bool flushImmediately = false, + }) { final entityKey = EntitySyncKey.forStrategyOp(op); - if (entityKey == null) { - return; - } + if (entityKey == null) return Future.value(); final pageId = entityKey.pageId; if (pageId != null) { - syncDesiredOpsForPage( + return syncDesiredOpsForPage( pageId: pageId, desiredOpsByEntityKey: {entityKey: op}, clearMissing: false, flushImmediately: flushImmediately, ); - return; } - - final queued = Map.from( - state.queuedByEntityKey, - ); - queued[entityKey] = QueuedEntityIntent( + return syncDesiredGenericOp( entityKey: entityKey, - pending: PendingOp( - op: op, - clientId: state.clientId ?? const Uuid().v4(), - ), + desiredOp: op, + flushImmediately: flushImmediately, ); - state = state.copyWith( - queuedByEntityKey: queued, - clearError: true, - ); - _scheduleFlush(flushImmediately: flushImmediately); } - void enqueueAll(Iterable ops, {bool flushImmediately = false}) { - final opsByPage = >{}; - final genericQueued = Map.from( - state.queuedByEntityKey, - ); - + Future enqueueAll( + Iterable ops, { + bool flushImmediately = false, + }) async { + final byPage = >{}; for (final op in ops) { - final entityKey = EntitySyncKey.forStrategyOp(op); - if (entityKey == null) { - continue; - } - final pageId = entityKey.pageId; - if (pageId == null) { - genericQueued[entityKey] = QueuedEntityIntent( - entityKey: entityKey, - pending: PendingOp( - op: op, - clientId: state.clientId ?? const Uuid().v4(), - ), - ); - continue; + final key = EntitySyncKey.forStrategyOp(op); + if (key == null) continue; + if (key.pageId == null) { + await syncDesiredGenericOp(entityKey: key, desiredOp: op); + } else { + (byPage[key.pageId!] ??= {})[key] = op; } - opsByPage.putIfAbsent( - pageId, () => {})[entityKey] = op; } - - if (!mapEquals(genericQueued, state.queuedByEntityKey)) { - state = state.copyWith( - queuedByEntityKey: genericQueued, - clearError: true, - ); - } - - for (final entry in opsByPage.entries) { - syncDesiredOpsForPage( + for (final entry in byPage.entries) { + await syncDesiredOpsForPage( pageId: entry.key, desiredOpsByEntityKey: entry.value, clearMissing: false, - flushImmediately: false, ); } _scheduleFlush(flushImmediately: flushImmediately); } - void syncDesiredGenericOp({ + Future syncDesiredGenericOp({ required EntitySyncKey entityKey, required StrategyOp? desiredOp, bool flushImmediately = false, }) { - final queued = Map.from( - state.queuedByEntityKey, - ); - final existingQueued = queued[entityKey]; - final inFlight = state.inFlightByEntityKey[entityKey]?.pending.op; - - if (desiredOp == null) { - if (queued.remove(entityKey) == null) { - return; - } - state = state.copyWith( - queuedByEntityKey: queued, - clearError: true, - ); - return; - } - - if (inFlight != null && _sameIntent(desiredOp, inFlight)) { - if (queued.remove(entityKey) == null) { - return; - } - state = state.copyWith( - queuedByEntityKey: queued, - clearError: true, - ); - return; - } - - if (existingQueued != null && - _sameIntent(existingQueued.pending.op, desiredOp)) { - return; - } - - final mergedDesired = existingQueued == null - ? desiredOp - : _mergeQueuedIntent(existingQueued.pending.op, desiredOp); - if (mergedDesired == null) { - if (queued.remove(entityKey) == null) { - return; - } - state = state.copyWith( - queuedByEntityKey: queued, - clearError: true, - ); - return; - } - - queued[entityKey] = QueuedEntityIntent( - entityKey: entityKey, - pending: PendingOp( - op: mergedDesired, - clientId: state.clientId ?? const Uuid().v4(), - attempts: existingQueued?.pending.attempts ?? 0, - lastAttemptAt: existingQueued?.pending.lastAttemptAt, - ), - ); - - state = state.copyWith( - queuedByEntityKey: queued, - clearError: true, - ); - _scheduleFlush(flushImmediately: flushImmediately); + return _serializeWrite(() => _syncDesiredLocked( + keys: {entityKey}, + desiredOps: {entityKey: desiredOp}, + flushImmediately: flushImmediately, + )); } - void syncDesiredOpsForPage({ + Future syncDesiredOpsForPage({ required String pageId, required Map desiredOpsByEntityKey, bool clearMissing = true, bool flushImmediately = false, }) { + return _serializeWrite(() async { + final keys = clearMissing + ? { + ...state.queuedByEntityKey.keys + .where((key) => key.pageId == pageId), + ...state.pausedByEntityKey.keys + .where((key) => key.pageId == pageId), + ...desiredOpsByEntityKey.keys, + } + : desiredOpsByEntityKey.keys.toSet(); + await _syncDesiredLocked( + keys: keys, + desiredOps: { + for (final key in keys) key: desiredOpsByEntityKey[key], + }, + flushImmediately: flushImmediately, + ); + }); + } + + Future _syncDesiredLocked({ + required Set keys, + required Map desiredOps, + required bool flushImmediately, + }) async { + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + if (accountId == null || strategyPublicId == null) { + if (desiredOps.values.any((op) => op != null)) { + state = state.copyWith( + lastError: + 'Cloud work could not be queued without an active account.', + ); + } + return; + } + final queued = Map.from( state.queuedByEntityKey, ); - final pageKeys = clearMissing - ? { - ...queued.keys.where((key) => key.pageId == pageId), - ...desiredOpsByEntityKey.keys, - } - : desiredOpsByEntityKey.keys.toSet(); - + final paused = Map.from( + state.pausedByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); var changed = false; - for (final key in pageKeys) { - final desired = desiredOpsByEntityKey[key]; - final existingQueued = queued[key]; - final inFlight = state.inFlightByEntityKey[key]?.pending.op; - - if (desired == null) { - if (queued.remove(key) != null) { - changed = true; - _debugLog('queued.drop $key reason=returned_to_remote_base'); + try { + for (final key in keys) { + final desired = desiredOps[key]; + final existing = queued[key]; + final inFlight = state.inFlightByEntityKey[key]?.pending.op; + final pausedIntent = paused[key]; + final attentionIntent = attention[key]; + + if (desired == null) { + final current = existing ?? pausedIntent ?? attentionIntent; + if (current != null) { + await _removeRecordIfCurrent(key, current.pending.op.opId); + queued.remove(key); + paused.remove(key); + attention.remove(key); + changed = true; + } + continue; } - continue; - } - if (inFlight != null && _sameIntent(desired, inFlight)) { - if (queued.remove(key) != null) { - changed = true; - _debugLog('queued.drop $key reason=covered_by_in_flight'); + if (inFlight != null && _sameIntent(desired, inFlight)) { + if (existing != null) { + await _removeRecordIfCurrent(key, existing.pending.op.opId); + queued.remove(key); + changed = true; + } + continue; } - continue; - } - if (existingQueued != null && - _sameIntent(existingQueued.pending.op, desired)) { - continue; - } - - final mergedDesired = existingQueued == null - ? desired - : _mergeQueuedIntent(existingQueued.pending.op, desired); - if (mergedDesired == null) { - if (queued.remove(key) != null) { - changed = true; - _debugLog('queued.drop $key reason=coalesced_to_noop'); + if (existing != null && _sameIntent(existing.pending.op, desired)) { + continue; } - continue; - } - queued[key] = QueuedEntityIntent( - entityKey: key, - pending: PendingOp( - op: mergedDesired, - clientId: state.clientId ?? const Uuid().v4(), - attempts: existingQueued?.pending.attempts ?? 0, - lastAttemptAt: existingQueued?.pending.lastAttemptAt, - ), - ); - changed = true; - _debugLog( - existingQueued == null - ? 'queued.upsert $key kind=${mergedDesired.kind.name}' - : 'queued.replace $key kind=${mergedDesired.kind.name}', - ); - } + // A rejected opId is an immutable server event. Reconciliation must + // replace it with the newly based op instead of replaying the reject. + final base = attentionIntent ?? pausedIntent ?? existing; + final merged = attentionIntent != null + ? desired + : (base == null + ? desired + : _mergeQueuedIntent(base.pending.op, desired)); + if (merged == null) { + if (base != null) { + await _removeRecordIfCurrent(key, base.pending.op.opId); + queued.remove(key); + paused.remove(key); + attention.remove(key); + changed = true; + } + continue; + } - if (!changed) { + final pending = PendingOp( + op: merged, + clientId: base?.pending.clientId ?? state.clientId!, + attempts: attentionIntent != null ? 0 : (base?.pending.attempts ?? 0), + lastAttemptAt: + attentionIntent != null ? null : base?.pending.lastAttemptAt, + ); + final record = _recordFor( + key: key, + pending: pending, + status: DurableOutboxStatus.queued, + ); + await _putRecord(record); + queued[key] = QueuedEntityIntent(entityKey: key, pending: pending); + paused.remove(key); + attention.remove(key); + changed = true; + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); return; } - + if (!changed) return; + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: paused, + attention: attention, + ); state = state.copyWith( queuedByEntityKey: queued, - clearError: true, + pausedByEntityKey: paused, + attentionByEntityKey: attention, + lastError: attentionMessage, + clearError: attentionMessage == null, ); _scheduleFlush(flushImmediately: flushImmediately); } - /// Clears a stale [StrategyOpQueueState.lastError] once nothing is left to - /// send — e.g. after ops were dropped at max attempts, where the error - /// would otherwise stick forever with no flush able to clear it. void clearStaleError() { if (state.lastError == null || state.isFlushing || - state.queuedByEntityKey.isNotEmpty || - state.inFlightByEntityKey.isNotEmpty) { - return; - } + state.pending.isNotEmpty || + state.loadIssues.isNotEmpty) return; state = state.copyWith(clearError: true); } - Future flushNow() async { - if (state.isFlushing) { - return; - } + Future retryPaused({bool flushImmediately = true}) { + return _serializeWrite(() async { + if (state.pausedByEntityKey.isEmpty) return; + final queued = Map.from( + state.queuedByEntityKey, + ); + try { + for (final entry in state.pausedByEntityKey.entries) { + final pending = PendingOp( + op: entry.value.pending.op, + clientId: entry.value.pending.clientId, + ); + await _putRecord(_recordFor( + key: entry.key, + pending: pending, + status: DurableOutboxStatus.queued, + )); + queued[entry.key] = + QueuedEntityIntent(entityKey: entry.key, pending: pending); + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; + } + state = state.copyWith( + queuedByEntityKey: queued, + pausedByEntityKey: const {}, + clearError: + state.attentionByEntityKey.isEmpty && state.loadIssues.isEmpty, + ); + _scheduleFlush(flushImmediately: flushImmediately); + }); + } + + /// Rebases server-rejected intents only after an explicit user action. + /// + /// The server revision is stored with the durable attention record, so the + /// same recovery remains available after an app restart. Ordinary page + /// reconciliation never removes these records. + Future retryRejected({bool flushImmediately = true}) { + return _serializeWrite(() async { + if (state.attentionByEntityKey.isEmpty) return; + final queued = Map.from( + state.queuedByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); + var changed = false; + try { + for (final entry in state.attentionByEntityKey.entries) { + final record = _recordForActiveKey(entry.key); + final rejected = entry.value.pending; + final rejectedOp = rejected.op; + final retryRevision = + record?.latestServerRevision ?? rejectedOp.expectedRevision; + if (retryRevision == null) continue; + final isTombstoneRestore = rejectedOp.kind == StrategyOpKind.add && + (rejectedOp.entityType == StrategyOpEntityType.element || + rejectedOp.entityType == StrategyOpEntityType.lineup) && + (record?.lastError == 'missing_expected_revision' || + record?.lastError == 'revision_mismatch'); + final rebasedKind = !isTombstoneRestore && + rejectedOp.kind == StrategyOpKind.add && + (rejectedOp.entityType == StrategyOpEntityType.element || + rejectedOp.entityType == StrategyOpEntityType.lineup) + ? StrategyOpKind.patch + : rejectedOp.kind; + final rebasedOp = StrategyOp( + opId: const Uuid().v4(), + kind: rebasedKind, + entityType: rejectedOp.entityType, + entityPublicId: rejectedOp.entityPublicId, + pagePublicId: rejectedOp.pagePublicId, + payload: rejectedOp.payload, + sortIndex: rejectedOp.sortIndex, + expectedRevision: retryRevision, + ); + final pending = PendingOp( + op: rebasedOp, + clientId: rejected.clientId, + ); + await _putRecord(_recordFor( + key: entry.key, + pending: pending, + status: DurableOutboxStatus.queued, + )); + queued[entry.key] = QueuedEntityIntent( + entityKey: entry.key, + pending: pending, + ); + attention.remove(entry.key); + changed = true; + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; + } + if (!changed) { + state = state.copyWith( + lastError: 'Some retained cloud work cannot be retried ' + 'automatically because the server has no matching revision.', + ); + return; + } + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: state.pausedByEntityKey, + attention: attention, + ); + state = state.copyWith( + queuedByEntityKey: queued, + attentionByEntityKey: attention, + lastError: attentionMessage, + clearError: attentionMessage == null, + ); + _scheduleFlush(flushImmediately: flushImmediately); + }); + } + Future flushNow() async { + await _writeTail; + if (state.isFlushing) return; final strategyPublicId = state.strategyPublicId; - if (strategyPublicId == null || state.queuedByEntityKey.isEmpty) { - return; - } + if (strategyPublicId == null || state.queuedByEntityKey.isEmpty) return; - final auth = ref.read(authProvider); final mode = ref.read(cloudCollabModeProvider); - final isConnected = ConvexClient.instance.isConnected; - - if (!mode.featureFlagEnabled || mode.forceLocalFallback) { + if (!mode.featureFlagEnabled || mode.forceLocalFallback) return; + final auth = ref.read(authProvider); + if (auth.hasActiveAuthIncident) { + state = state.copyWith( + lastError: 'Cloud auth incident active. Saved work is paused.', + ); return; } - - if (auth.hasActiveAuthIncident) { + if (auth.user?.id != state.accountId) { state = state.copyWith( - lastError: 'Cloud auth incident active. Awaiting user action.', + lastError: 'Cloud outbox belongs to a different account.', ); return; } - - if (!auth.isAuthenticated || !auth.isConvexUserReady || !isConnected) { - final networkError = !auth.isAuthenticated + if (!auth.isAuthenticated || + !auth.isConvexUserReady || + !ConvexClient.instance.isConnected) { + final message = !auth.isAuthenticated ? 'Not authenticated for cloud sync.' : (!auth.isConvexUserReady ? 'Cloud user setup is not ready.' : 'Cloud connection is offline.'); _scheduleRetry( - state.queuedByEntityKey.values.map((intent) => intent.pending).toList(), + state.queuedByEntityKey.values.map((item) => item.pending).toList(), delay: _offlineRetryDelay(), ); - state = state.copyWith( - lastError: networkError, - ); + state = state.copyWith(lastError: message); return; } - final batch = state.queuedByEntityKey.values - .where((intent) => - !state.inFlightByEntityKey.containsKey(intent.entityKey)) + final candidates = state.queuedByEntityKey.values.toList(growable: false); + if (candidates.isEmpty) return; + final batchClientId = candidates.first.pending.clientId; + final batch = candidates + .where((intent) => intent.pending.clientId == batchClientId) .take(_maxBatchSize) .toList(growable: false); - if (batch.isEmpty) { - return; - } - final queued = Map.from( state.queuedByEntityKey, ); @@ -413,19 +569,24 @@ class StrategyOpQueueNotifier extends Notifier { state.inFlightByEntityKey, ); final sentAt = DateTime.now(); - final batchByOpId = {}; - for (final intent in batch) { - queued.remove(intent.entityKey); - inFlight[intent.entityKey] = InFlightEntityIntent( - entityKey: intent.entityKey, - pending: intent.pending, - sentAt: sentAt, - ); - batchByOpId[intent.pending.op.opId] = intent; - _debugLog( - 'inflight.send ${intent.entityKey} op=${intent.pending.op.opId}'); + try { + for (final intent in batch) { + await _putRecord(_recordFor( + key: intent.entityKey, + pending: intent.pending, + status: DurableOutboxStatus.inFlight, + )); + queued.remove(intent.entityKey); + inFlight[intent.entityKey] = InFlightEntityIntent( + entityKey: intent.entityKey, + pending: intent.pending, + sentAt: sentAt, + ); + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; } - state = state.copyWith( queuedByEntityKey: queued, inFlightByEntityKey: inFlight, @@ -436,122 +597,213 @@ class StrategyOpQueueNotifier extends Notifier { try { _retryTimer?.cancel(); _retryTimer = null; - _offlineRetryCount = 0; final acks = await _repo.applyBatch( strategyPublicId: strategyPublicId, - clientId: state.clientId ?? const Uuid().v4(), + clientId: batchClientId, ops: batch.map((intent) => intent.pending.op).toList(growable: false), ); + await _applyAcks(batch, acks); + if (state.queuedByEntityKey.isNotEmpty) unawaited(flushNow()); + } catch (error, stackTrace) { + if (isConvexUnauthenticatedError(error)) { + unawaited(ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: 'strategy_op_queue:flush', + error: error, + stackTrace: stackTrace, + )); + } else { + log('Failed flushing op queue: $error', + error: error, stackTrace: stackTrace); + } + await _restoreBatchAfterFailure(batch, lastError: '$error'); + } + } - final latestQueued = Map.from( - state.queuedByEntityKey, - ); - final latestInFlight = Map.from( - state.inFlightByEntityKey, + Future _applyAcks( + List batch, + List acks, + ) async { + final byOpId = {for (final item in batch) item.pending.op.opId: item}; + final ackByOpId = {for (final ack in acks) ack.opId: ack}; + if (ackByOpId.length != batch.length) { + throw StateError( + 'Server returned an incomplete operation result batch.', ); - final acked = []; + } + final inFlight = Map.from( + state.inFlightByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); + final acked = []; + try { for (final ack in acks) { - final sent = batchByOpId[ack.opId]; - if (sent == null) { - continue; - } - latestInFlight.remove(sent.entityKey); - acked.add( - AckedEntityIntent( + final sent = byOpId[ack.opId]; + if (sent == null) continue; + inFlight.remove(sent.entityKey); + acked.add(AckedEntityIntent( + entityKey: sent.entityKey, + op: sent.pending.op, + ack: ack, + )); + final current = _recordForActiveKey(sent.entityKey); + if (current?.pending.op.opId != ack.opId) continue; + if (ack.isAck) { + await _removeRecordIfCurrent(sent.entityKey, ack.opId); + } else { + final rejected = current!.copyWith( + status: DurableOutboxStatus.attention, + updatedAt: DateTime.now(), + lastError: ack.reason ?? 'The server rejected this change.', + latestServerRevision: ack.latestRevision, + ); + await _putRecord(rejected); + attention[sent.entityKey] = QueuedEntityIntent( entityKey: sent.entityKey, - op: sent.pending.op, - ack: ack, - ), - ); - _debugLog( - 'inflight.${ack.isAck ? 'ack' : 'reject'} ${sent.entityKey} op=${ack.opId}', - ); - } - - for (final sent in batch) { - latestInFlight.remove(sent.entityKey); - } - - state = state.copyWith( - queuedByEntityKey: latestQueued, - inFlightByEntityKey: latestInFlight, - isFlushing: false, - lastFlushAt: DateTime.now(), - lastAcks: acks, - lastAckBatch: acked, - ); - - if (state.queuedByEntityKey.isNotEmpty) { - unawaited(flushNow()); + pending: sent.pending, + ); + } } } catch (error, stackTrace) { - if (isConvexUnauthenticatedError(error)) { - unawaited( - ref.read(authProvider.notifier).reportConvexUnauthenticated( - source: 'strategy_op_queue:flush', - error: error, - stackTrace: stackTrace, - ), - ); - _restoreBatchAfterFailure( - batch, - lastError: 'Cloud authentication expired. Retry required.', - ); - return; - } - - log( - 'Failed flushing op queue: $error', - error: error, - stackTrace: stackTrace, - ); - _restoreBatchAfterFailure(batch, lastError: '$error'); + _recordPersistenceFailure(error, stackTrace); + return; } + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: state.pausedByEntityKey, + attention: attention, + ); + state = state.copyWith( + inFlightByEntityKey: inFlight, + attentionByEntityKey: attention, + isFlushing: false, + lastFlushAt: DateTime.now(), + lastAcks: acks, + lastAckBatch: acked, + lastError: attentionMessage, + clearError: attentionMessage == null, + ); } - void _restoreBatchAfterFailure( + Future _restoreBatchAfterFailure( List batch, { required String lastError, - }) { + }) async { final queued = Map.from( state.queuedByEntityKey, ); final inFlight = Map.from( state.inFlightByEntityKey, ); - final retried = []; - final droppedEntityKeys = []; - - for (final sent in batch) { - inFlight.remove(sent.entityKey); - if (queued.containsKey(sent.entityKey)) { - continue; - } - final retriedPending = sent.pending.incrementAttempt(); - if (retriedPending.attempts > _maxAttempts) { - droppedEntityKeys.add(sent.entityKey); - _debugLog( - 'queued.drop ${sent.entityKey} reason=max_attempts_reached ' - 'attempts=${retriedPending.attempts}', + final paused = Map.from( + state.pausedByEntityKey, + ); + final retrying = []; + try { + for (final sent in batch) { + inFlight.remove(sent.entityKey); + if (queued.containsKey(sent.entityKey)) continue; + final pending = sent.pending.incrementAttempt(); + final isPaused = pending.attempts >= _maxAttempts; + await _putRecord(_recordFor( + key: sent.entityKey, + pending: pending, + status: isPaused + ? DurableOutboxStatus.paused + : DurableOutboxStatus.queued, + lastError: lastError, + )); + final intent = QueuedEntityIntent( + entityKey: sent.entityKey, + pending: pending, ); - continue; + if (isPaused) { + paused[sent.entityKey] = intent; + } else { + queued[sent.entityKey] = intent; + retrying.add(pending); + } } - queued[sent.entityKey] = QueuedEntityIntent( - entityKey: sent.entityKey, - pending: retriedPending, - ); - retried.add(retriedPending); - _debugLog('queued.retry ${sent.entityKey} reason=flush_failure'); + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; } - state = state.copyWith( queuedByEntityKey: queued, inFlightByEntityKey: inFlight, + pausedByEntityKey: paused, isFlushing: false, - lastError: _appendDroppedAttemptError(lastError, droppedEntityKeys), + lastError: paused.isEmpty + ? lastError + : '$lastError (retry paused after $_maxAttempts attempts)', + ); + _scheduleRetry(retrying); + } + + DurableOutboxRecord _recordFor({ + required EntitySyncKey key, + required PendingOp pending, + required DurableOutboxStatus status, + String? lastError, + }) { + final current = _recordForActiveKey(key); + final now = DateTime.now(); + return DurableOutboxRecord( + accountId: state.accountId!, + strategyPublicId: state.strategyPublicId!, + entityKey: key, + pending: pending, + status: status, + createdAt: current?.createdAt ?? now, + updatedAt: now, + lastError: lastError, + ); + } + + DurableOutboxRecord? _recordForActiveKey(EntitySyncKey key) { + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + if (accountId == null || strategyPublicId == null) return null; + return _recordsByStorageKey[DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: key, + )]; + } + + Future _putRecord(DurableOutboxRecord record) async { + await _store.put(record); + _recordsByStorageKey[record.storageKey] = record; + } + + Future _removeRecordIfCurrent( + EntitySyncKey key, + String opId, + ) async { + final record = _recordForActiveKey(key); + if (record == null || record.pending.op.opId != opId) return; + await _store.remove(record.storageKey); + _recordsByStorageKey.remove(record.storageKey); + } + + Future _serializeWrite(Future Function() action) { + final next = _writeTail.then((_) => action()); + _writeTail = next.catchError((Object error, StackTrace stackTrace) { + log('Outbox write failed: $error', + name: 'strategy_outbox', error: error, stackTrace: stackTrace); + }); + return next; + } + + void _recordPersistenceFailure(Object error, StackTrace stackTrace) { + log('Durable outbox persistence failed: $error', + name: 'strategy_outbox', error: error, stackTrace: stackTrace); + state = state.copyWith( + isFlushing: false, + lastError: 'Cloud work could not be saved to the durable outbox: $error', ); - _scheduleRetry(retried); } void _scheduleFlush({required bool flushImmediately}) { @@ -559,40 +811,26 @@ class StrategyOpQueueNotifier extends Notifier { unawaited(flushNow()); return; } - _debounceTimer?.cancel(); - _debounceTimer = Timer(_debounceDelay, () { - unawaited(flushNow()); - }); + _debounceTimer = Timer(_debounceDelay, () => unawaited(flushNow())); } - void _scheduleRetry( - List pending, { - Duration? delay, - }) { - if (pending.isEmpty) { - return; - } - + void _scheduleRetry(List pending, {Duration? delay}) { + if (pending.isEmpty) return; final maxAttempt = pending.fold( 0, - (acc, next) => math.max(acc, next.attempts), + (value, item) => math.max(value, item.attempts), ); - final delayMs = (delay ?? - Duration(milliseconds: 300 * (1 << maxAttempt.clamp(0, 6)))) - .inMilliseconds; - + final retryDelay = + delay ?? Duration(milliseconds: 300 * (1 << maxAttempt.clamp(0, 6))); _retryTimer?.cancel(); - _retryTimer = Timer(Duration(milliseconds: delayMs), () { - unawaited(flushNow()); - }); + _retryTimer = Timer(retryDelay, () => unawaited(flushNow())); } Duration _offlineRetryDelay() { final exponent = _offlineRetryCount.clamp(0, 6); - final delay = Duration(milliseconds: 300 * (1 << exponent)); _offlineRetryCount += 1; - return delay; + return Duration(milliseconds: 300 * (1 << exponent)); } bool _sameIntent(StrategyOp left, StrategyOp right) { @@ -602,27 +840,12 @@ class StrategyOpQueueNotifier extends Notifier { left.pagePublicId == right.pagePublicId && cloudJsonEquivalent(left.payload, right.payload) && left.sortIndex == right.sortIndex && - left.expectedRevision == right.expectedRevision && - left.expectedSequence == right.expectedSequence; - } - - String _appendDroppedAttemptError( - String baseError, - List droppedEntityKeys, - ) { - if (droppedEntityKeys.isEmpty) { - return baseError; - } - final entityKeys = droppedEntityKeys.join(', '); - return '$baseError (dropped after $_maxAttempts attempts: $entityKeys)'; + left.expectedRevision == right.expectedRevision; } StrategyOp? _mergeQueuedIntent(StrategyOp existing, StrategyOp desired) { if (desired.kind == StrategyOpKind.delete && - existing.kind == StrategyOpKind.add) { - return null; - } - + existing.kind == StrategyOpKind.add) return null; if (existing.kind == StrategyOpKind.add && desired.kind == StrategyOpKind.patch) { return StrategyOp( @@ -634,10 +857,8 @@ class StrategyOpQueueNotifier extends Notifier { payload: desired.payload ?? existing.payload, sortIndex: desired.sortIndex ?? existing.sortIndex, expectedRevision: existing.expectedRevision, - expectedSequence: existing.expectedSequence, ); } - return StrategyOp( opId: existing.opId, kind: desired.kind, @@ -647,14 +868,19 @@ class StrategyOpQueueNotifier extends Notifier { payload: desired.payload ?? existing.payload, sortIndex: desired.sortIndex ?? existing.sortIndex, expectedRevision: desired.expectedRevision ?? existing.expectedRevision, - expectedSequence: desired.expectedSequence ?? existing.expectedSequence, ); } - void _debugLog(String message) { - assert(() { - log(message, name: 'strategy_op_queue'); - return true; - }()); + String? _loadedAttentionMessage({ + required List loadIssues, + required Map paused, + required Map attention, + }) { + if (loadIssues.isNotEmpty) { + return 'The cloud outbox contains unreadable saved work.'; + } + if (attention.isNotEmpty) return 'Some saved work needs attention.'; + if (paused.isNotEmpty) return 'Some saved work is paused after retries.'; + return null; } } diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index fffac5e0..0810e586 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -1,4 +1,4 @@ -import 'package:convex_flutter/convex_flutter.dart'; +import 'package:icarus/collab/convex_client.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index 8a565fd2..8b1dc2cd 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -22,6 +22,7 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/transition_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/strategy/strategy_page_apply.dart'; @@ -70,27 +71,18 @@ class StrategyPageSessionState { class _RemotePageHydrationKey { const _RemotePageHydrationKey({ required this.strategyPublicId, - required this.sequence, required this.pageId, required this.fingerprint, }); final String strategyPublicId; - final int sequence; final String pageId; final String fingerprint; - bool sameTargetAs(_RemotePageHydrationKey other) { - return strategyPublicId == other.strategyPublicId && - sequence == other.sequence && - pageId == other.pageId; - } - @override bool operator ==(Object other) { return other is _RemotePageHydrationKey && strategyPublicId == other.strategyPublicId && - sequence == other.sequence && pageId == other.pageId && fingerprint == other.fingerprint; } @@ -98,7 +90,6 @@ class _RemotePageHydrationKey { @override int get hashCode => Object.hash( strategyPublicId, - sequence, pageId, fingerprint, ); @@ -111,14 +102,12 @@ final strategyPageSessionProvider = class StrategyPageSessionNotifier extends Notifier { _RemotePageHydrationKey? _lastHydratedRemotePageKey; - _RemotePageHydrationKey? _lastSequenceAdvancedHydrationKey; bool _pendingRemoteReapply = false; - bool _pendingRemoteSequenceAdvanced = false; @override StrategyPageSessionState build() { - ref.listen>( - remoteStrategySnapshotProvider, + ref.listen>( + remoteEditorSnapshotProvider, (previous, next) { final strategyState = ref.read(strategyProvider); if (strategyState.source != StrategySource.cloud || @@ -150,29 +139,10 @@ class StrategyPageSessionNotifier extends Notifier { return; } - final prevSequence = previous?.valueOrNull?.header.sequence; - final sequenceChanged = - prevSequence == null || prevSequence != snapshot.header.sequence; - final sequenceAdvanced = - prevSequence != null && prevSequence != snapshot.header.sequence; - - if (sequenceChanged) { - if (_lastHydratedRemotePageKey == hydrationKey) { - return; - } + if (_lastHydratedRemotePageKey != hydrationKey) { _requestRemoteRehydrate( targetPageId, hydrationKey: hydrationKey, - sequenceAdvanced: sequenceAdvanced, - ); - return; - } - - if (_shouldRehydrateLateSectionReplacement(hydrationKey)) { - _requestRemoteRehydrate( - targetPageId, - hydrationKey: hydrationKey, - sequenceAdvanced: false, ); } }, @@ -182,6 +152,12 @@ class StrategyPageSessionNotifier extends Notifier { _resumePendingRemoteReapplyIfPossible(); }); + ref.listen>(textDraftProvider, (previous, next) { + if (next.isEmpty && (previous?.isNotEmpty ?? false)) { + _resumePendingRemoteReapplyIfPossible(); + } + }); + ref.listen(strategyOpQueueProvider, (previous, next) { final previousAckBatch = previous?.lastAckBatch ?? const []; @@ -246,6 +222,7 @@ class StrategyPageSessionNotifier extends Notifier { if (pageId == state.activePageId) { return; } + final previousPageId = state.activePageId; final transitionState = ref.read(transitionProvider); final transitionNotifier = ref.read(transitionProvider.notifier); @@ -269,11 +246,37 @@ class StrategyPageSessionNotifier extends Notifier { startAbilitySize: startSettings.abilitySize, ); - await _switchToPage( - pageId, - animated: true, - direction: direction, - ); + try { + await _switchToPage( + pageId, + animated: true, + direction: direction, + ); + } catch (error, stackTrace) { + transitionNotifier.complete(); + final strategyState = ref.read(strategyProvider); + state = state.copyWith( + activePageId: previousPageId, + clearActivePageId: previousPageId == null, + transitionState: PageTransitionState.idle, + ); + ref.read(activePageLiveSyncProvider.notifier).setContext( + strategyPublicId: strategyState.strategyId, + activePageId: previousPageId, + ); + if (strategyState.source == StrategySource.cloud) { + try { + await ref + .read(remoteEditorSnapshotProvider.notifier) + .setActivePage(previousPageId); + } catch (_) { + // Preserve the original switch failure; the live read can recover + // independently without leaving the transition state stuck. + } + } + _resumePendingRemoteReapplyIfPossible(); + Error.throwWithStackTrace(error, stackTrace); + } final endSettings = ref.read(strategySettingsProvider); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -354,9 +357,7 @@ class StrategyPageSessionNotifier extends Notifier { isApplyingPage: false, ); _lastHydratedRemotePageKey = null; - _lastSequenceAdvancedHydrationKey = null; _pendingRemoteReapply = false; - _pendingRemoteSequenceAdvanced = false; ref.read(activePageLiveSyncProvider.notifier).reset(); } @@ -378,11 +379,18 @@ class StrategyPageSessionNotifier extends Notifier { } await pageSource.flushCurrentPage(); if (source == StrategySource.cloud) { - await ref.read(strategyOpQueueProvider.notifier).flushNow(); + await ref + .read(strategyOpQueueProvider.notifier) + .flushNow() + .timeout(const Duration(milliseconds: 750), onTimeout: () {}); + state = state.copyWith(activePageId: pageId); ref.read(activePageLiveSyncProvider.notifier).setContext( strategyPublicId: strategyId, activePageId: pageId, ); + await ref + .read(remoteEditorSnapshotProvider.notifier) + .setActivePage(pageId); } final pageData = await pageSource.loadPage(pageId); @@ -400,7 +408,6 @@ class StrategyPageSessionNotifier extends Notifier { Future _rehydrateActivePageFromSource( String pageId, { _RemotePageHydrationKey? hydrationKey, - bool sequenceAdvanced = false, }) async { final strategyState = ref.read(strategyProvider); final strategyId = strategyState.strategyId; @@ -420,7 +427,6 @@ class StrategyPageSessionNotifier extends Notifier { strategyId: strategyId, source: source, hydrationKey: hydrationKey, - sequenceAdvanced: sequenceAdvanced, ); } @@ -429,7 +435,6 @@ class StrategyPageSessionNotifier extends Notifier { required String strategyId, required StrategySource source, _RemotePageHydrationKey? hydrationKey, - bool sequenceAdvanced = false, }) async { final preserveHistory = source == StrategySource.cloud && _lastHydratedRemotePageKey?.strategyPublicId == strategyId && @@ -456,7 +461,6 @@ class StrategyPageSessionNotifier extends Notifier { _updateHydrationBookkeeping( pageData.pageId, hydrationKey: hydrationKey, - sequenceAdvanced: sequenceAdvanced, ); } finally { state = state.copyWith( @@ -489,7 +493,7 @@ class StrategyPageSessionNotifier extends Notifier { String _resolveThemeProfileId(StrategySource source, String strategyId) { if (source == StrategySource.cloud) { - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; return snapshot?.header.themeProfileId ?? MapThemeProfilesProvider.immutableDefaultProfileId; } @@ -507,7 +511,7 @@ class StrategyPageSessionNotifier extends Notifier { ) { if (source == StrategySource.cloud) { final payload = ref - .read(remoteStrategySnapshotProvider) + .read(remoteEditorSnapshotProvider) .valueOrNull ?.header .themeOverridePalette; @@ -527,31 +531,32 @@ class StrategyPageSessionNotifier extends Notifier { } bool _canSafelyReapplyRemotePage() { + final saveState = ref.read(strategySaveStateProvider); return !state.isApplyingPage && - state.transitionState == PageTransitionState.idle; + state.transitionState == PageTransitionState.idle && + ref.read(textDraftProvider).isEmpty && + !saveState.isDirty && + !saveState.isSaving && + !saveState.hasPendingCloudSync; } void _requestRemoteRehydrate( String pageId, { required _RemotePageHydrationKey hydrationKey, - required bool sequenceAdvanced, }) { if (_canSafelyReapplyRemotePage()) { unawaited( _rehydrateActivePageFromSource( pageId, hydrationKey: hydrationKey, - sequenceAdvanced: sequenceAdvanced, ), ); } else { _pendingRemoteReapply = true; - _pendingRemoteSequenceAdvanced = - _pendingRemoteSequenceAdvanced || sequenceAdvanced; } } - String? _resolveHydrationTargetPage(RemoteStrategySnapshot snapshot) { + String? _resolveHydrationTargetPage(RemoteEditorSnapshot snapshot) { if (snapshot.pages.isEmpty) { return null; } @@ -570,20 +575,16 @@ class StrategyPageSessionNotifier extends Notifier { void _updateHydrationBookkeeping( String pageId, { _RemotePageHydrationKey? hydrationKey, - bool sequenceAdvanced = false, }) { final key = hydrationKey ?? _currentRemotePageHydrationKey(pageId); if (key == null) { return; } _lastHydratedRemotePageKey = key; - if (sequenceAdvanced) { - _lastSequenceAdvancedHydrationKey = key; - } } _RemotePageHydrationKey? _currentRemotePageHydrationKey(String pageId) { - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null) { return null; } @@ -591,7 +592,7 @@ class StrategyPageSessionNotifier extends Notifier { } _RemotePageHydrationKey? _buildRemotePageHydrationKey( - RemoteStrategySnapshot snapshot, + RemoteEditorSnapshot snapshot, String pageId, ) { RemotePage? page; @@ -604,6 +605,10 @@ class StrategyPageSessionNotifier extends Notifier { if (page == null) { return null; } + final pageSnapshot = snapshot.activePage; + if (pageSnapshot == null || pageSnapshot.page.publicId != pageId) { + return null; + } final elements = [ ...snapshot.elementsByPage[pageId] ?? const [] @@ -621,7 +626,8 @@ class StrategyPageSessionNotifier extends Notifier { 'sortIndex': page.sortIndex, 'isAttack': page.isAttack, 'revision': page.revision, - 'settings': page.settings, + 'contentRevision': pageSnapshot.content.revision, + 'settings': pageSnapshot.content.settings, }, 'elements': [ for (final element in elements) @@ -660,7 +666,6 @@ class StrategyPageSessionNotifier extends Notifier { return _RemotePageHydrationKey( strategyPublicId: snapshot.header.publicId, - sequence: snapshot.header.sequence, pageId: pageId, fingerprint: fingerprint, ); @@ -682,18 +687,6 @@ class StrategyPageSessionNotifier extends Notifier { return a.publicId.compareTo(b.publicId); } - bool _shouldRehydrateLateSectionReplacement( - _RemotePageHydrationKey hydrationKey, - ) { - final lastHydratedKey = _lastHydratedRemotePageKey; - final sequenceAdvancedKey = _lastSequenceAdvancedHydrationKey; - return lastHydratedKey != null && - sequenceAdvancedKey != null && - hydrationKey.sameTargetAs(lastHydratedKey) && - hydrationKey.sameTargetAs(sequenceAdvancedKey) && - hydrationKey.fingerprint != lastHydratedKey.fingerprint; - } - Future _reconcileAcks( List acks, List ackBatch, @@ -722,12 +715,15 @@ class StrategyPageSessionNotifier extends Notifier { message: ack.reason, serverPayload: serverPayload, serverRevision: ack.latestRevision, - serverSequence: ack.latestSequence, ), ); } - await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + if (!_canSafelyReapplyRemotePage()) { + _pendingRemoteReapply = true; + return; + } final activePageId = state.activePageId; final strategyId = strategyState.strategyId; if (activePageId != null && strategyId != null) { @@ -737,11 +733,13 @@ class StrategyPageSessionNotifier extends Notifier { strategyPublicId: strategyId, pageId: activePageId, ); - ref.read(strategyOpQueueProvider.notifier).syncDesiredOpsForPage( - pageId: activePageId, - desiredOpsByEntityKey: desiredOpsByEntityKey, - flushImmediately: false, - ); + if (desiredOpsByEntityKey != null) { + await ref.read(strategyOpQueueProvider.notifier).syncDesiredOpsForPage( + pageId: activePageId, + desiredOpsByEntityKey: desiredOpsByEntityKey, + flushImmediately: false, + ); + } if (_canSafelyReapplyRemotePage()) { await _rehydrateActivePageFromSource(activePageId); } else { @@ -756,16 +754,11 @@ class StrategyPageSessionNotifier extends Notifier { if (!_pendingRemoteReapply || !_canSafelyReapplyRemotePage()) { return; } - final sequenceAdvanced = _pendingRemoteSequenceAdvanced; _pendingRemoteReapply = false; - _pendingRemoteSequenceAdvanced = false; final pageId = state.activePageId; if (pageId != null) { unawaited( - _rehydrateActivePageFromSource( - pageId, - sequenceAdvanced: sequenceAdvanced, - ), + _rehydrateActivePageFromSource(pageId), ); } } diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 997c005b..b9165f86 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; -import 'package:convex_flutter/convex_flutter.dart'; +import 'package:icarus/collab/convex_client.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/const/placed_classes.dart'; @@ -243,9 +243,9 @@ class StrategyProvider extends Notifier { ref.read(strategySaveStateProvider.notifier).reset(); await ref - .read(remoteStrategySnapshotProvider.notifier) + .read(remoteEditorSnapshotProvider.notifier) .openStrategy(strategyID); - final snapshotState = ref.read(remoteStrategySnapshotProvider); + final snapshotState = ref.read(remoteEditorSnapshotProvider); final snapshot = snapshotState.valueOrNull; if (snapshot == null) { // Returning silently here used to leave the editor on an eternal @@ -315,6 +315,17 @@ class StrategyProvider extends Notifier { ..setCloudSyncError(null); } + Future _enqueueCloudPageDescriptorOp(StrategyOp op) async { + await enqueueOps([op]); + final queue = ref.read(strategyOpQueueProvider.notifier); + await queue.flushNow(); + return ref + .read(strategyOpQueueProvider) + .lastAcks + .where((ack) => ack.opId == op.opId) + .firstOrNull; + } + Future notifyCloudMutation({bool flushImmediately = false}) async { _cloudMutationSyncScheduled = false; if (!_currentStrategyIsCloud()) { @@ -407,7 +418,7 @@ class StrategyProvider extends Notifier { StrategyOp? _buildDesiredStrategySyncOp() { final strategyId = state.strategyId; - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (strategyId == null || snapshot == null || snapshot.header.publicId != strategyId) { @@ -435,6 +446,7 @@ class StrategyProvider extends Notifier { kind: StrategyOpKind.patch, entityType: StrategyOpEntityType.strategy, entityPublicId: strategyId, + expectedRevision: snapshot.header.revision, payload: { 'mapData': localMapData, if (localThemeProfileId != null) 'themeProfileId': localThemeProfileId, @@ -539,7 +551,7 @@ class StrategyProvider extends Notifier { storageDirectory: state.storageDirectory, isOpen: false, ); - ref.read(remoteStrategySnapshotProvider.notifier).clear(); + ref.read(remoteEditorSnapshotProvider.notifier).clear(); unawaited( ref.read(cloudMediaUploadQueueProvider.notifier).setActiveStrategy(null), ); @@ -566,7 +578,7 @@ class StrategyProvider extends Notifier { if (oldIndex == newIndex) return; if (_currentStrategyIsCloud()) { - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null || snapshot.pages.isEmpty) return; final ordered = [...snapshot.pages] ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); @@ -583,21 +595,17 @@ class StrategyProvider extends Notifier { final moved = ordered.removeAt(oldIndex); ordered.insert(targetIndex, moved); - try { - await ConvexClient.instance.mutation(name: "pages:reorder", args: { - "strategyPublicId": state.strategyId, - "orderedPagePublicIds": ordered.map((p) => p.publicId).toList(), - }); - } catch (error, stackTrace) { - final handled = await _reportCloudUnauthenticated( - source: 'strategy:pages_reorder', - error: error, - stackTrace: stackTrace, - ); - if (!handled) rethrow; - return; + final ack = await _enqueueCloudPageDescriptorOp(StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.reorder, + entityType: StrategyOpEntityType.page, + entityPublicId: moved.publicId, + sortIndex: targetIndex, + expectedRevision: snapshot.header.revision, + )); + if (ack != null) { + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); } - await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); return; } @@ -646,37 +654,34 @@ class StrategyProvider extends Notifier { Future addPage([String? name]) async { if (_currentStrategyIsCloud()) { - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null) return; final pages = [...snapshot.pages] ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); final pageID = const Uuid().v4(); final nextIndex = pages.length; - try { - await ConvexClient.instance.mutation(name: "pages:add", args: { - "strategyPublicId": state.strategyId, - "pagePublicId": pageID, - "name": name ?? "Page ${pages.length + 1}", - "sortIndex": nextIndex, - "isAttack": pages.isNotEmpty ? pages.last.isAttack : true, - "settings": ref.read(strategySettingsProvider).toJson(), - }); - } catch (error, stackTrace) { - final handled = await _reportCloudUnauthenticated( - source: 'strategy:pages_add', - error: error, - stackTrace: stackTrace, - ); - if (!handled) rethrow; - return; + final ack = await _enqueueCloudPageDescriptorOp(StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.add, + entityType: StrategyOpEntityType.page, + entityPublicId: pageID, + payload: { + 'name': name ?? 'Page ${pages.length + 1}', + 'isAttack': pages.isNotEmpty ? pages.last.isAttack : true, + 'settings': ref.read(strategySettingsProvider).toJson(), + }, + sortIndex: nextIndex, + expectedRevision: snapshot.header.revision, + )); + if (ack?.isAck ?? false) { + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + await ref + .read(strategyPageSessionProvider.notifier) + .setActivePageAnimated( + pageID, + direction: PageTransitionDirection.forward, + ); } - await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); - await ref - .read(strategyPageSessionProvider.notifier) - .setActivePageAnimated( - pageID, - direction: PageTransitionDirection.forward, - ); return; } @@ -726,22 +731,22 @@ class StrategyProvider extends Notifier { } if (_currentStrategyIsCloud()) { - try { - await ConvexClient.instance.mutation(name: "pages:rename", args: { - "strategyPublicId": state.strategyId, - "pagePublicId": pageId, - "name": trimmed, - }); - } catch (error, stackTrace) { - final handled = await _reportCloudUnauthenticated( - source: 'strategy:pages_rename', - error: error, - stackTrace: stackTrace, - ); - if (!handled) rethrow; - return; + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final page = snapshot?.pages + .where((candidate) => candidate.publicId == pageId) + .firstOrNull; + if (page == null) return; + final ack = await _enqueueCloudPageDescriptorOp(StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.page, + entityPublicId: pageId, + payload: {'name': trimmed}, + expectedRevision: page.revision, + )); + if (ack != null) { + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); } - await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); return; } @@ -763,7 +768,7 @@ class StrategyProvider extends Notifier { Future deletePage(String pageId) async { if (_currentStrategyIsCloud()) { - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null || snapshot.pages.length <= 1) { return; } @@ -784,23 +789,17 @@ class StrategyProvider extends Notifier { ); } - try { - await ConvexClient.instance.mutation(name: "pages:delete", args: { - "strategyPublicId": state.strategyId, - "pagePublicId": pageId, - }); - } catch (error, stackTrace) { - final handled = await _reportCloudUnauthenticated( - source: 'strategy:pages_delete', - error: error, - stackTrace: stackTrace, - ); - if (!handled) rethrow; - return; + final ack = await _enqueueCloudPageDescriptorOp(StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.delete, + entityType: StrategyOpEntityType.page, + entityPublicId: pageId, + expectedRevision: snapshot.header.revision, + )); + if (ack?.isAck ?? false) { + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); } - - await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); - if (nextActivePageId != activePageId) { + if ((ack?.isAck ?? false) && nextActivePageId != activePageId) { await ref .read(strategyPageSessionProvider.notifier) .setActivePageAnimated( @@ -1016,9 +1015,17 @@ class StrategyProvider extends Notifier { final resolvedSource = source ?? _resolveLibraryMutationSource(); if (resolvedSource == StrategySource.cloud) { try { + final shell = state.strategyId == strategyID && + state.source == StrategySource.cloud + ? ref.read(remoteEditorSnapshotProvider).valueOrNull?.shell + : await ref + .read(convexStrategyRepositoryProvider) + .fetchShell(strategyID); + if (shell == null) return; await ConvexClient.instance.mutation(name: "strategies:update", args: { "strategyPublicId": strategyID, "name": newName, + "expectedRevision": shell.header.revision, }); } catch (error, stackTrace) { final handled = await _reportCloudUnauthenticated( @@ -1031,7 +1038,7 @@ class StrategyProvider extends Notifier { } if (state.strategyId == strategyID && state.source == StrategySource.cloud) { - await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); } else { ref.invalidate(cloudStrategiesProvider); } @@ -1061,10 +1068,10 @@ class StrategyProvider extends Notifier { try { final snapshot = await ref .read(convexStrategyRepositoryProvider) - .fetchSnapshot(strategyID); + .fetchFullSnapshot(strategyID); final newStrategyID = const Uuid().v4(); final pages = [...snapshot.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + ..sort((a, b) => a.page.sortIndex.compareTo(b.page.sortIndex)); final firstPage = pages.isNotEmpty ? pages.first : null; final firstPageId = const Uuid().v4(); await ref @@ -1074,9 +1081,9 @@ class StrategyProvider extends Notifier { name: "${snapshot.header.name} (Copy)", mapData: snapshot.header.mapData, initialPagePublicId: firstPageId, - initialPageName: firstPage?.name ?? "Page 1", - initialPageIsAttack: firstPage?.isAttack ?? true, - initialPageSettings: firstPage?.settings, + initialPageName: firstPage?.page.name ?? "Page 1", + initialPageIsAttack: firstPage?.page.isAttack ?? true, + initialPageSettings: firstPage?.content.settings, folderPublicId: ref.read(folderProvider), themeProfileId: snapshot.header.themeProfileId, themeOverridePalette: snapshot.header.themeOverridePalette, @@ -1084,10 +1091,12 @@ class StrategyProvider extends Notifier { final pageIdMap = {}; if (firstPage != null) { - pageIdMap[firstPage.publicId] = firstPageId; + pageIdMap[firstPage.page.publicId] = firstPageId; } + var expectedStrategyRevision = 0; for (var i = firstPage == null ? 0 : 1; i < pages.length; i++) { - final page = pages[i]; + final fullPage = pages[i]; + final page = fullPage.page; final newPageId = const Uuid().v4(); pageIdMap[page.publicId] = newPageId; await ConvexClient.instance.mutation(name: "pages:add", args: { @@ -1096,12 +1105,16 @@ class StrategyProvider extends Notifier { "name": page.name, "sortIndex": page.sortIndex, "isAttack": page.isAttack, - if (page.settings != null) "settings": page.settings, + if (fullPage.content.settings != null) + "settings": fullPage.content.settings, + "expectedRevision": expectedStrategyRevision, }); + expectedStrategyRevision += 1; } final ops = []; - for (final page in pages) { + for (final fullPage in pages) { + final page = fullPage.page; final newPageId = pageIdMap[page.publicId]; if (newPageId == null) continue; @@ -1199,8 +1212,12 @@ class StrategyProvider extends Notifier { final resolvedSource = source ?? _resolveLibraryMutationSource(); if (resolvedSource == StrategySource.cloud) { try { + final shell = await ref + .read(convexStrategyRepositoryProvider) + .fetchShell(strategyID); await ConvexClient.instance.mutation(name: "strategies:delete", args: { "strategyPublicId": strategyID, + "expectedRevision": shell.header.revision, }); } catch (error, stackTrace) { final handled = await _reportCloudUnauthenticated( @@ -1342,33 +1359,35 @@ class StrategyProvider extends Notifier { ) async { if (_currentStrategyIsCloud()) { final strategyId = state.strategyId; - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; - if (strategyId == null || snapshot == null || snapshot.pages.isEmpty) { + if (strategyId == null) { return; } + final snapshot = await ref + .read(convexStrategyRepositoryProvider) + .fetchFullSnapshot(strategyId); + if (snapshot.pages.isEmpty) return; final ops = [ - for (final page in snapshot.pages) + for (final fullPage in snapshot.pages) StrategyOp( opId: const Uuid().v4(), kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.page, - entityPublicId: page.publicId, - pagePublicId: page.publicId, + entityType: StrategyOpEntityType.pageContent, + entityPublicId: fullPage.page.publicId, + pagePublicId: fullPage.page.publicId, payload: { - 'settings': - transform(_settingsFromPayloadOrDefault(page.settings)) - .toJson(), + 'settings': transform(_settingsFromPayloadOrDefault( + fullPage.content.settings, + )).toJson(), }, + expectedRevision: fullPage.content.revision, ), ]; try { - await ref.read(convexStrategyRepositoryProvider).applyBatch( - strategyPublicId: strategyId, - clientId: const Uuid().v4(), - ops: ops, - ); + await ref + .read(strategyOpQueueProvider.notifier) + .enqueueAll(ops, flushImmediately: true); } catch (error, stackTrace) { final handled = await _reportCloudUnauthenticated( source: 'strategy:apply_settings_to_all_pages', @@ -1378,8 +1397,7 @@ class StrategyProvider extends Notifier { if (!handled) rethrow; return; } - await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); - ref.read(strategySaveStateProvider.notifier).markPersisted(); + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); return; } @@ -1432,9 +1450,13 @@ class StrategyProvider extends Notifier { if (resolvedSource == StrategySource.cloud) { unawaited(() async { try { + final shell = await ref + .read(convexStrategyRepositoryProvider) + .fetchShell(strategyID); await ConvexClient.instance.mutation(name: "strategies:move", args: { "strategyPublicId": strategyID, if (parentID != null) "folderPublicId": parentID, + "expectedRevision": shell.header.revision, }); } catch (error, stackTrace) { await _reportCloudUnauthenticated( diff --git a/lib/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index 52f1a1c1..c6e82c40 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:convex_flutter/convex_flutter.dart'; +import 'package:icarus/collab/convex_client.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; diff --git a/lib/strategy/strategy_import_export.dart b/lib/strategy/strategy_import_export.dart index 9fe45d3c..8eaef93e 100644 --- a/lib/strategy/strategy_import_export.dart +++ b/lib/strategy/strategy_import_export.dart @@ -2376,11 +2376,12 @@ class StrategyImportExportService { } Future _ensureRemoteAssetsCached( - RemoteStrategySnapshot snapshot, + RemoteFullStrategySnapshot snapshot, ) async { final strategyId = snapshot.header.publicId; final assetIds = {}; - for (final page in snapshot.pages) { + for (final fullPage in snapshot.pages) { + final page = fullPage.page; for (final element in snapshot.elementsByPage[page.publicId] ?? const []) { if (element.deleted || element.elementType != 'image') { @@ -2423,7 +2424,7 @@ class StrategyImportExportService { Future exportCloudStrategy(String strategyId) async { final snapshot = await ref .read(convexStrategyRepositoryProvider) - .fetchSnapshot(strategyId); + .fetchFullSnapshot(strategyId); await _ensureRemoteAssetsCached(snapshot); final strategy = _strategyDataFromRemoteSnapshot(snapshot); final outputFile = await FilePicker.platform.saveFile( @@ -2452,15 +2453,17 @@ class StrategyImportExportService { } StrategyData _strategyDataFromRemoteSnapshot( - RemoteStrategySnapshot snapshot) { + RemoteFullStrategySnapshot snapshot) { final pages = []; final mapValue = Maps.mapNames.entries .where((entry) => entry.value == snapshot.header.mapData) .map((entry) => entry.key) .first; - for (final remotePage in snapshot.pages - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex))) { + final orderedPages = [...snapshot.pages] + ..sort((a, b) => a.page.sortIndex.compareTo(b.page.sortIndex)); + for (final fullPage in orderedPages) { + final remotePage = fullPage.page; final elements = snapshot.elementsByPage[remotePage.publicId] ?? const []; final lineups = snapshot.lineupsByPage[remotePage.publicId] ?? const []; final drawingData = []; @@ -2509,9 +2512,10 @@ class StrategyImportExportService { } StrategySettings settings = StrategySettings(); - if (remotePage.settings != null && remotePage.settings!.isNotEmpty) { + final settingsPayload = fullPage.content.settings; + if (settingsPayload != null && settingsPayload.isNotEmpty) { try { - settings = StrategySettings.fromJson(remotePage.settings!); + settings = StrategySettings.fromJson(settingsPayload); } catch (_) {} } diff --git a/lib/strategy/strategy_page_source.dart b/lib/strategy/strategy_page_source.dart index 5a1269ed..796b49c7 100644 --- a/lib/strategy/strategy_page_source.dart +++ b/lib/strategy/strategy_page_source.dart @@ -150,8 +150,8 @@ class CloudStrategyPageSource implements StrategyPageSource { final String strategyId; final String? Function() activePageId; - RemoteStrategySnapshot get _snapshot { - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + RemoteEditorSnapshot get _snapshot { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null) { throw StateError('Remote snapshot unavailable for $strategyId.'); } @@ -167,6 +167,17 @@ class CloudStrategyPageSource implements StrategyPageSource { @override Future loadPage(String pageId) async { + if (ref + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.activePage + ?.page + .publicId != + pageId) { + await ref + .read(remoteEditorSnapshotProvider.notifier) + .setActivePage(pageId); + } final snapshot = _snapshot; final pages = [...snapshot.pages] ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); @@ -188,8 +199,13 @@ class CloudStrategyPageSource implements StrategyPageSource { return _hydrateProjectedPage(snapshot, page, projected); } - final elements = snapshot.elementsByPage[page.publicId] ?? const []; - final lineups = snapshot.lineupsByPage[page.publicId] ?? const []; + final pageSnapshot = snapshot.activePage; + if (pageSnapshot == null || pageSnapshot.page.publicId != page.publicId) { + throw StateError( + 'Remote page snapshot unavailable for ${page.publicId}.'); + } + final elements = pageSnapshot.elements; + final lineups = pageSnapshot.lineups; final agents = []; final abilities = []; @@ -261,9 +277,10 @@ class CloudStrategyPageSource implements StrategyPageSource { ); StrategySettings pageSettings = StrategySettings(); - if (page.settings != null && page.settings!.isNotEmpty) { + final settingsPayload = pageSnapshot.content.settings; + if (settingsPayload != null && settingsPayload.isNotEmpty) { try { - pageSettings = StrategySettings.fromJson(page.settings!); + pageSettings = StrategySettings.fromJson(settingsPayload); } catch (_) { pageSettings = StrategySettings(); } @@ -292,22 +309,25 @@ class CloudStrategyPageSource implements StrategyPageSource { return; } - _syncStrategyMetadata(); + await _syncStrategyMetadata(); final desiredOpsByEntityKey = ref.read(activePageLiveSyncProvider.notifier).syncLocalPage( strategyPublicId: strategyId, pageId: pageId, ); - ref.read(strategyOpQueueProvider.notifier).syncDesiredOpsForPage( + if (desiredOpsByEntityKey == null) { + return; + } + await ref.read(strategyOpQueueProvider.notifier).syncDesiredOpsForPage( pageId: pageId, desiredOpsByEntityKey: desiredOpsByEntityKey, flushImmediately: false, ); } - void _syncStrategyMetadata() { - final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + Future _syncStrategyMetadata() async { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null) { return; } @@ -328,7 +348,7 @@ class CloudStrategyPageSource implements StrategyPageSource { cloudJsonEquivalent(header.themeOverridePalette, desiredThemeOverride); if (mapMatches && themeProfileMatches && themeOverrideMatches) { - ref.read(strategyOpQueueProvider.notifier).syncDesiredGenericOp( + await ref.read(strategyOpQueueProvider.notifier).syncDesiredGenericOp( entityKey: const EntitySyncKey.strategy(), desiredOp: null, flushImmediately: false, @@ -348,21 +368,21 @@ class CloudStrategyPageSource implements StrategyPageSource { 'clearThemeOverridePalette': true, }; - ref.read(strategyOpQueueProvider.notifier).syncDesiredGenericOp( + await ref.read(strategyOpQueueProvider.notifier).syncDesiredGenericOp( entityKey: const EntitySyncKey.strategy(), desiredOp: StrategyOp( opId: const Uuid().v4(), kind: StrategyOpKind.patch, entityType: StrategyOpEntityType.strategy, payload: payload, - expectedSequence: header.sequence, + expectedRevision: header.revision, ), flushImmediately: false, ); } StrategyEditorPageData _hydrateProjectedPage( - RemoteStrategySnapshot snapshot, + RemoteEditorSnapshot snapshot, RemotePage page, ActivePageProjectedState projected, ) { diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 5f8899df..1ed3000d 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -211,29 +211,31 @@ class _StrategyViewState extends ConsumerState Padding( padding: const EdgeInsets.only(left: 15, top: 15, bottom: 10, right: 15), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( + child: LayoutBuilder( + builder: (context, constraints) { + final showDiscordLabel = constraints.maxWidth >= 1000; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: _leaveToLibrary, - icon: const Icon(Icons.home), + Row( + children: [ + ShadIconButton.ghost( + foregroundColor: Colors.white, + onPressed: _leaveToLibrary, + icon: const Icon(Icons.home), + ), + const SizedBox(width: 5), + const MapSelector(), + if (kIsWeb) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: DemoTag(), + ), + ], ), - const SizedBox(width: 5), - const MapSelector(), - if (kIsWeb) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: DemoTag(), - ) - ], - ), - const StrategyQuickSwitcher(), - Row( - children: [ - TextButton( + const StrategyQuickSwitcher(), + if (showDiscordLabel) + TextButton( style: TextButton.styleFrom( foregroundColor: Colors.white, ), @@ -243,18 +245,28 @@ class _StrategyViewState extends ConsumerState child: const Row( children: [ Text("Have any bugs? Join the Discord"), - SizedBox( - width: 10, - ), + SizedBox(width: 10), Icon( CustomIcons.discord, color: Colors.white, - ) + ), ], - )), + ), + ) + else + Tooltip( + message: 'Have any bugs? Join the Discord', + child: ShadIconButton.ghost( + foregroundColor: Colors.white, + onPressed: () async { + await launchUrl(Settings.dicordLink); + }, + icon: const Icon(CustomIcons.discord), + ), + ), ], - ) - ], + ); + }, ), ), const Expanded( diff --git a/lib/widgets/cloud_sync_status_chip.dart b/lib/widgets/cloud_sync_status_chip.dart index 5e42c396..365db773 100644 --- a/lib/widgets/cloud_sync_status_chip.dart +++ b/lib/widgets/cloud_sync_status_chip.dart @@ -21,9 +21,8 @@ enum _SyncStatus { synced, syncing, offline, attention } /// /// Renders nothing for local strategies. For cloud strategies it shows one of /// synced / syncing / offline / needs-attention, with a popover explaining the -/// state and offering retry when something failed. Also surfaces conflicts -/// (server rejected an edit and the view was rebased) as a toast — previously -/// those were collected and never shown. +/// state and offering recovery when something failed. Also surfaces conflicts +/// (the server rejected an edit while retaining the local intent) as a toast. class CloudSyncStatusChip extends ConsumerStatefulWidget { const CloudSyncStatusChip({super.key}); @@ -72,10 +71,9 @@ class _CloudSyncStatusChipState extends ConsumerState { void _showConflictToast() { _lastConflictToast = DateTime.now(); Settings.showToast( - message: - 'A collaborator changed this page — your view was updated to the ' - 'latest version.', - backgroundColor: Settings.tacticalVioletTheme.primary, + message: 'Another edit reached the cloud first. Your version is still on ' + 'this device and needs attention.', + backgroundColor: Settings.tacticalVioletTheme.destructive, ); ref.read(strategyConflictProvider.notifier).clearAll(); } @@ -86,18 +84,15 @@ class _CloudSyncStatusChipState extends ConsumerState { .read(cloudMediaUploadQueueProvider.notifier) .retryNow(ignoreBackoff: true); final opQueue = ref.read(strategyOpQueueProvider.notifier); + await opQueue.retryPaused(flushImmediately: false); + await opQueue.retryRejected(flushImmediately: false); await opQueue.flushNow(); - // If everything queued was already dropped (max attempts), flushNow is a - // no-op and the old error would pin the chip on "needs attention" with a - // Retry that does nothing — clear it; the page has since rebased onto - // the server state. opQueue.clearStaleError(); } @override Widget build(BuildContext context) { - final source = - ref.watch(strategyProvider.select((state) => state.source)); + final source = ref.watch(strategyProvider.select((state) => state.source)); ref.listen(strategyConflictProvider, (previous, next) { _onConflicts(previous?.length ?? 0, next.length); }); @@ -107,17 +102,20 @@ class _CloudSyncStatusChipState extends ConsumerState { } final saveState = ref.watch(strategySaveStateProvider); + final opQueueState = ref.watch(strategyOpQueueProvider); final isConnected = ref.watch(convexConnectionProvider).valueOrNull ?? true; final _SyncStatus status; - if (!isConnected) { - status = _SyncStatus.offline; - } else if (saveState.cloudSyncError != null || + if (opQueueState.needsAttention || + saveState.cloudSyncError != null || saveState.mediaSyncErrorCount > 0) { status = _SyncStatus.attention; + } else if (!isConnected) { + status = _SyncStatus.offline; } else if (saveState.isSaving || saveState.hasPendingCloudSync || - saveState.hasPendingMediaSync) { + saveState.hasPendingMediaSync || + !opQueueState.durableLoaded) { status = _SyncStatus.syncing; } else { status = _SyncStatus.synced; @@ -134,6 +132,7 @@ class _CloudSyncStatusChipState extends ConsumerState { popover: (context) => _SyncStatusPopover( status: status, saveState: saveState, + hasRejectedWork: opQueueState.attentionByEntityKey.isNotEmpty, onRetry: _retry, ), child: Padding( @@ -189,8 +188,7 @@ class _CloudSyncStatusChipState extends ConsumerState { Color _chipBackground(_SyncStatus status) { switch (status) { case _SyncStatus.attention: - return Settings.tacticalVioletTheme.destructive - .withValues(alpha: 0.14); + return Settings.tacticalVioletTheme.destructive.withValues(alpha: 0.14); case _SyncStatus.offline: case _SyncStatus.syncing: case _SyncStatus.synced: @@ -264,11 +262,13 @@ class _SyncStatusPopover extends StatelessWidget { const _SyncStatusPopover({ required this.status, required this.saveState, + required this.hasRejectedWork, required this.onRetry, }); final _SyncStatus status; final StrategySaveState saveState; + final bool hasRejectedWork; final Future Function() onRetry; @override @@ -312,7 +312,7 @@ class _SyncStatusPopover extends StatelessWidget { size: ShadButtonSize.sm, onPressed: onRetry, leading: const Icon(LucideIcons.refreshCw, size: 14), - child: const Text('Retry sync'), + child: Text(hasRejectedWork ? 'Keep my version' : 'Retry sync'), ), ], ], @@ -351,8 +351,17 @@ class _SyncStatusPopover extends StatelessWidget { String get _attentionExplanation { final mediaErrors = saveState.mediaSyncErrorCount; final parts = []; + if (hasRejectedWork) { + parts.add( + 'Another edit reached the cloud first. Your version remains saved ' + 'on this device.', + ); + } final error = saveState.cloudSyncError; - if (error != null) { + final retryUnavailable = + error?.toLowerCase().contains('cannot be retried automatically') ?? + false; + if (error != null && (!hasRejectedWork || retryUnavailable)) { parts.add(_friendlyError(error)); } if (mediaErrors > 0) { @@ -365,12 +374,33 @@ class _SyncStatusPopover extends StatelessWidget { if (parts.isEmpty) { parts.add("Some changes haven't reached the cloud yet."); } - parts.add('Retry to send them now.'); + parts.add( + hasRejectedWork + ? 'Choose Keep my version to send your retained edit again.' + : 'Retry to send them now.', + ); return parts.join(' '); } static String _friendlyError(String raw) { final lower = raw.toLowerCase(); + if (lower.contains('unreadable saved work')) { + return 'A saved cloud change could not be read. It remains on this ' + 'device; keep this strategy open and recover the outbox before ' + 'continuing.'; + } + if (lower.contains('retry paused')) { + return 'A saved cloud change is paused after repeated failures. Retry ' + 'when the connection and account are healthy.'; + } + if (lower.contains('needs attention')) { + return 'Another edit reached the cloud first. Your version remains ' + 'saved on this device.'; + } + if (lower.contains('cannot be retried automatically')) { + return 'The server cannot match this retained edit to a current cloud ' + 'revision. It remains saved on this device.'; + } if (lower.contains('auth')) { return 'Your cloud session needs to be refreshed — retry, or sign in ' 'again from the library.'; diff --git a/lib/widgets/dialogs/strategy/line_up_media_page.dart b/lib/widgets/dialogs/strategy/line_up_media_page.dart index eb4ca0f9..5a9d9c2b 100644 --- a/lib/widgets/dialogs/strategy/line_up_media_page.dart +++ b/lib/widgets/dialogs/strategy/line_up_media_page.dart @@ -263,7 +263,7 @@ class _LineupMediaPageState extends ConsumerState { final String fullImagePath = path.join(imageFolderPath!.path, image.id + image.fileExtension); final file = File(fullImagePath); - final snapshot = ref.watch(remoteStrategySnapshotProvider).valueOrNull; + final snapshot = ref.watch(remoteEditorSnapshotProvider).valueOrNull; final fallbackUrl = snapshot?.assetsById[image.id]?.url; final ImageProvider? imageProvider = file.existsSync() diff --git a/lib/widgets/draggable_widgets/image/image_widget.dart b/lib/widgets/draggable_widgets/image/image_widget.dart index 97c0d25e..25d33cd5 100644 --- a/lib/widgets/draggable_widgets/image/image_widget.dart +++ b/lib/widgets/draggable_widgets/image/image_widget.dart @@ -171,7 +171,7 @@ class _ImageWidgetState extends ConsumerState { )); final strategyState = ref.watch(strategyProvider); final remoteAsset = ref - .watch(remoteStrategySnapshotProvider) + .watch(remoteEditorSnapshotProvider) .valueOrNull ?.assetsById[widget.id]; final remoteUrl = remoteAsset?.url; diff --git a/lib/widgets/line_up_media_carousel.dart b/lib/widgets/line_up_media_carousel.dart index 974d614b..40b694cc 100644 --- a/lib/widgets/line_up_media_carousel.dart +++ b/lib/widgets/line_up_media_carousel.dart @@ -115,7 +115,7 @@ class _ImageCarouselState extends ConsumerState imageFolderPath!.path, image.id + image.fileExtension); final file = File(fullPath); final snapshot = - ref.watch(remoteStrategySnapshotProvider).valueOrNull; + ref.watch(remoteEditorSnapshotProvider).valueOrNull; final remoteUrl = snapshot?.assetsById[image.id]?.url; if (!file.existsSync() && @@ -246,22 +246,22 @@ class _ImageCarouselState extends ConsumerState Navigator.of(context).pop(); ref.read(actionProvider.notifier).performTransaction( - groups: const [ActionGroup.lineUp], - mutation: () { - ref.read(lineUpProvider.notifier).deleteItem( - groupId: widget.lineUpGroupId, - itemId: widget.lineUpItemId, - ); - }, - ); + groups: const [ActionGroup.lineUp], + mutation: () { + ref.read(lineUpProvider.notifier).deleteItem( + groupId: widget.lineUpGroupId, + itemId: widget.lineUpItemId, + ); + }, + ); }, ), ShadButton( // height: 32, leading: const Icon(LucideIcons.pencil), // width: 80, - child: const Text("Edit"), - onPressed: () { + child: const Text("Edit"), + onPressed: () { Navigator.of(context).pop(); showDialog( context: context, diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index 2cf0767e..40ed72ad 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -408,7 +408,7 @@ class _PagesBarState extends ConsumerState { } _PageBarData? _buildCloudData(String? activePageId) { - final snapshot = ref.watch(remoteStrategySnapshotProvider).valueOrNull; + final snapshot = ref.watch(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null || snapshot.pages.isEmpty) { return null; } diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index 90bb0e34..24af4723 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -8,10 +8,10 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; -import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; +import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Displays the current strategy name with a recent-strategies dropdown. @@ -361,16 +361,15 @@ class _StrategyQuickSwitcherState extends ConsumerState { horizontal: 12, vertical: 8, ), - child: Shortcuts( - shortcuts: { - ...ShortcutInfo.textEditingOverridesFor( - ref - .watch(appPreferencesProvider) - .customShortcutBindings, - ), - const SingleActivator( + child: TextEditingShortcutScope( + extraShortcuts: const { + SingleActivator( + LogicalKeyboardKey.enter, + ): EnterTextIntent(), + SingleActivator( LogicalKeyboardKey.escape, - ): const DismissIntent(), + ): DismissIntent(), }, child: Actions( actions: >{ diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index c9462283..120233c2 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -159,7 +159,6 @@ class _SkeletonTopBar extends StatelessWidget { return Padding( padding: const EdgeInsets.only(left: 15, top: 15, bottom: 10, right: 15), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ @@ -171,35 +170,59 @@ class _SkeletonTopBar extends StatelessWidget { ), ], ), - Padding( - padding: const EdgeInsets.all(16), - child: Container( - width: 280, - height: 40, - decoration: BoxDecoration( - color: _tone(Settings.tacticalVioletTheme.card, 0.95), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _tone(Settings.highlightColor, 0.82)), + const SizedBox(width: 12), + Expanded( + flex: 6, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Container( + width: double.infinity, + height: 40, + decoration: BoxDecoration( + color: _tone(Settings.tacticalVioletTheme.card, 0.95), + borderRadius: BorderRadius.circular(8), + border: + Border.all(color: _tone(Settings.highlightColor, 0.82)), + ), + child: Center( + child: title == null || title.isEmpty + ? const _SkeletonBlock( + width: 158, + height: 12, + radius: 5, + ) + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ShadTheme.of(context) + .textTheme + .small + .copyWith(color: Colors.white70), + ), + ), + ), + ), ), - child: Center( - child: title == null || title.isEmpty - ? const _SkeletonBlock(width: 158, height: 12, radius: 5) - : Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ShadTheme.of(context) - .textTheme - .small - .copyWith(color: Colors.white70), - ), - ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 5, + child: Align( + alignment: Alignment.centerRight, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 238), + child: const SizedBox( + width: double.infinity, + child: _SkeletonBlock(height: 40, radius: 8), + ), ), ), ), - const _SkeletonBlock(width: 238, height: 40, radius: 8), ], ), ); diff --git a/lib/widgets/text_editing_shortcut_scope.dart b/lib/widgets/text_editing_shortcut_scope.dart index 63fa2631..ddc89a19 100644 --- a/lib/widgets/text_editing_shortcut_scope.dart +++ b/lib/widgets/text_editing_shortcut_scope.dart @@ -22,14 +22,24 @@ class TextEditingShortcutScope extends ConsumerWidget { ? ref.watch(appPreferencesProvider).customShortcutBindings : const {}; + // Shortcut priority follows widget nesting from the focused field out: + // field-specific commands, native text editing, then app shortcut blockers. + final textField = DefaultTextEditingShortcuts( + child: extraShortcuts.isEmpty + ? child + : Shortcuts( + shortcuts: extraShortcuts, + child: child, + ), + ); + return Shortcuts( shortcuts: { ...ShortcutInfo.textEditingOverridesFor( customShortcutBindings, ), - ...extraShortcuts, }, - child: child, + child: textField, ); } } diff --git a/package-lock.json b/package-lock.json index 9e610db2..fe1ce501 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,15 +6,41 @@ "": { "name": "icarus", "dependencies": { - "convex": "^1.32.0" + "convex": "1.45.0" }, "devDependencies": { - "@types/bun": "latest" + "@edge-runtime/vm": "^3.2.0", + "@types/bun": "latest", + "convex-test": "^0.0.41", + "vitest": "^1.6.1" }, "peerDependencies": { "typescript": "^5" } }, + "node_modules/@edge-runtime/primitives": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-4.1.0.tgz", + "integrity": "sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/vm": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-3.2.0.tgz", + "integrity": "sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@edge-runtime/primitives": "4.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", @@ -431,166 +457,1994 @@ "node": ">=18" } }, - "node_modules/@types/bun": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.11.tgz", - "integrity": "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==", + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, "license": "MIT", "dependencies": { - "bun-types": "1.3.11" + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/bun-types": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.11.tgz", - "integrity": "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/convex": { - "version": "1.34.1", - "resolved": "https://registry.npmjs.org/convex/-/convex-1.34.1.tgz", - "integrity": "sha512-ooyFnZVVq0u6b5zt0Ptq8QB2ixhf/2vXe+PIcUtdtrs0lq/TwpkmmruHdqkFmWgMd6N+Tmfy8AGkz6QnZUYZBA==", - "license": "Apache-2.0", - "dependencies": { - "esbuild": "0.27.0", - "prettier": "^3.0.0", - "ws": "8.18.0" - }, - "bin": { - "convex": "bin/main.js" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=7.0.0" - }, - "peerDependencies": { - "@auth0/auth0-react": "^2.0.1", - "@clerk/clerk-react": "^4.12.8 || ^5.0.0", - "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@auth0/auth0-react": { - "optional": true - }, - "@clerk/clerk-react": { - "optional": true - }, - "react": { - "optional": true - } - } + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/esbuild": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", - "hasInstallScript": true, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.0", - "@esbuild/android-arm": "0.27.0", - "@esbuild/android-arm64": "0.27.0", - "@esbuild/android-x64": "0.27.0", - "@esbuild/darwin-arm64": "0.27.0", - "@esbuild/darwin-x64": "0.27.0", - "@esbuild/freebsd-arm64": "0.27.0", - "@esbuild/freebsd-x64": "0.27.0", - "@esbuild/linux-arm": "0.27.0", - "@esbuild/linux-arm64": "0.27.0", - "@esbuild/linux-ia32": "0.27.0", - "@esbuild/linux-loong64": "0.27.0", - "@esbuild/linux-mips64el": "0.27.0", - "@esbuild/linux-ppc64": "0.27.0", - "@esbuild/linux-riscv64": "0.27.0", - "@esbuild/linux-s390x": "0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/netbsd-arm64": "0.27.0", - "@esbuild/netbsd-x64": "0.27.0", - "@esbuild/openbsd-arm64": "0.27.0", - "@esbuild/openbsd-x64": "0.27.0", - "@esbuild/openharmony-arm64": "0.27.0", - "@esbuild/sunos-x64": "0.27.0", - "@esbuild/win32-arm64": "0.27.0", - "@esbuild/win32-ia32": "0.27.0", - "@esbuild/win32-x64": "0.27.0" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bun": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.11.tgz", + "integrity": "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.11" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bun-types": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.11.tgz", + "integrity": "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/convex": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.45.0.tgz", + "integrity": "sha512-AV3B56Ptu/14d76g3urBJ50dwpiZa6uJaLT84OWox5aCwZIJiuAamdjv3lLHDzs8vv5kC31anEZohhUe3qJ2bA==", + "license": "Apache-2.0", + "dependencies": { + "esbuild": "0.27.0", + "prettier": "^3.0.0", + "ws": "8.21.0" + }, + "bin": { + "convex": "bin/main.js" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@auth0/auth0-react": "^2.0.1", + "@clerk/clerk-react": "^4.12.8 || ^5.0.0", + "@clerk/react": "^6.4.3", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@auth0/auth0-react": { + "optional": true + }, + "@clerk/clerk-react": { + "optional": true + }, + "@clerk/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/convex-test": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.41.tgz", + "integrity": "sha512-GPHeYFOi70n7UtW0eCEQFVhzl/+m8PvbWkDCbKpHLybI1MrScf4sVpGeM0cC2qmtxiduxa2nLPbehPalhh9oyQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "convex": "^1.16.4" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } } } diff --git a/package.json b/package.json index 5a172be7..0b24d995 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,19 @@ "module": "index.ts", "type": "module", "private": true, + "scripts": { + "test:convex": "vitest run --config vitest.config.ts" + }, "devDependencies": { - "@types/bun": "latest" + "@edge-runtime/vm": "^3.2.0", + "@types/bun": "latest", + "convex-test": "^0.0.41", + "vitest": "^1.6.1" }, "peerDependencies": { "typescript": "^5" }, "dependencies": { - "convex": "^1.32.0" + "convex": "1.45.0" } } diff --git a/test/collab_sync_models_test.dart b/test/collab_sync_models_test.dart index 2ef0342c..4be9357f 100644 --- a/test/collab_sync_models_test.dart +++ b/test/collab_sync_models_test.dart @@ -2,16 +2,16 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/canonical_json.dart'; import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/line_provider.dart'; import 'package:icarus/providers/collab/cloud_collab_provider.dart'; void main() { group('CloudCollabModeState', () { - test('is enabled for authenticated, ready users', () { + test('is enabled only for authenticated, ready users', () { const mode = CloudCollabModeState( featureFlagEnabled: true, forceLocalFallback: false, ); - expect( mode.isCloudEnabled( isAuthenticated: true, @@ -19,14 +19,20 @@ void main() { ), isTrue, ); + expect( + mode.isCloudEnabled( + isAuthenticated: false, + isConvexUserReady: true, + ), + isFalse, + ); }); - test('is disabled when force-local fallback is enabled', () { + test('force-local fallback wins over auth', () { const mode = CloudCollabModeState( featureFlagEnabled: true, forceLocalFallback: true, ); - expect( mode.isCloudEnabled( isAuthenticated: true, @@ -37,30 +43,27 @@ void main() { }); }); - group('StrategyOp model', () { - test('serializes only populated optional fields', () { + group('StrategyOp protocol', () { + test('serializes record revision without a global sequence', () { const op = StrategyOp( opId: 'op-1', kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.element, - entityPublicId: 'element-1', - payload: {'foo': 'bar'}, + entityType: StrategyOpEntityType.pageContent, + entityPublicId: 'page-1', + payload: {'settings': {}}, + expectedRevision: 4, ); final json = op.toConvexJson(); - - expect(json['opId'], 'op-1'); - expect(json['kind'], 'patch'); - expect(json['entityType'], 'element'); - expect(json['entityPublicId'], 'element-1'); - expect(json['payload'], {'foo': 'bar'}); - expect(json.containsKey('pagePublicId'), isFalse); - expect(json.containsKey('sortIndex'), isFalse); - expect(json.containsKey('expectedRevision'), isFalse); + expect(currentCloudProtocolVersion, 2); + expect(json['entityType'], 'pageContent'); + expect(json['expectedRevision'], 4); expect(json.containsKey('expectedSequence'), isFalse); + expect(StrategyOp.fromJson(json).entityType, + StrategyOpEntityType.pageContent); }); - test('copyWith updates expected values while preserving identity', () { + test('copyWith preserves identity', () { const original = StrategyOp( opId: 'op-2', kind: StrategyOpKind.patch, @@ -68,291 +71,179 @@ void main() { entityPublicId: 'lineup-1', pagePublicId: 'page-1', ); - - final updated = - original.copyWith(expectedRevision: 9, expectedSequence: 12); - + final updated = original.copyWith(expectedRevision: 9); expect(updated.opId, original.opId); expect(updated.entityPublicId, original.entityPublicId); - expect(updated.pagePublicId, original.pagePublicId); expect(updated.expectedRevision, 9); - expect(updated.expectedSequence, 12); }); }); - group('RemoteElement', () { - test('decodes valid payload object data', () { - const remote = RemoteElement( - publicId: 'el-1', - strategyPublicId: 'strat-1', - pagePublicId: 'page-1', - elementType: 'agent', - payload: { - 'kind': 'agent', - 'payloadVersion': 1, - 'data': {'id': 'agent-1', 'elementType': 'agent'}, - }, - sortIndex: 0, - revision: 1, - deleted: false, - ); + test('canonical JSON ignores key order and numeric representation', () { + final left = { + 'data': { + 'position': {'dx': 10, 'dy': 20.0}, + }, + }; + final right = { + 'data': { + 'position': {'dy': 20, 'dx': 10.0}, + }, + }; + expect(cloudJsonEquivalent(left, right), isTrue); + }); - expect(remote.decodedPayload()['id'], 'agent-1'); - expect(remote.decodedPayload()['elementType'], 'agent'); - }); + test('RemoteElement decodes the canonical data envelope', () { + const remote = RemoteElement( + publicId: 'el-1', + strategyPublicId: 'strat-1', + pagePublicId: 'page-1', + elementType: 'agent', + payload: { + 'kind': 'agent', + 'payloadVersion': 1, + 'data': {'id': 'agent-1'}, + }, + sortIndex: 0, + revision: 1, + deleted: false, + ); + expect(remote.decodedPayload()['id'], 'agent-1'); + }); - test('returns empty map for payload without data', () { - const remote = RemoteElement( - publicId: 'el-2', - strategyPublicId: 'strat-1', - pagePublicId: 'page-1', - elementType: 'agent', - payload: {}, - sortIndex: 0, - revision: 1, - deleted: false, - ); + test('cloud payload data normalizes nested bridge maps for lineup parsing', + () { + final payload = { + 'kind': 'lineupGroup', + 'payloadVersion': 1, + 'data': { + 'id': 'lineup-1', + 'agent': { + 'id': 'agent-1', + 'position': {'dx': 10, 'dy': 20}, + 'type': 'sova', + 'isAlly': true, + 'state': 'none', + 'lineUpID': 'lineup-1', + }, + 'items': [ + { + 'id': 'item-1', + 'ability': { + 'id': 'ability-1', + 'data': {'type': 'sova', 'index': 2}, + 'position': {'dx': 30, 'dy': 40}, + 'lineUpID': 'lineup-1', + }, + 'youtubeLink': '', + 'notes': 'proof', + 'images': [], + }, + ], + }, + }; - expect(remote.decodedPayload(), isEmpty); - }); + final group = LineUpGroup.fromJson(cloudPayloadData(payload)); + + expect(group.id, 'lineup-1'); + expect(group.items.single.notes, 'proof'); }); - group('canonical cloud JSON', () { - test('treats equivalent objects as equal regardless of key order', () { - final left = { - 'kind': 'text', - 'payloadVersion': 1, - 'data': { - 'id': 'text-1', - 'position': {'dx': 10, 'dy': 20.0}, - 'elementType': 'text', - }, - }; - final right = { - 'data': { - 'elementType': 'text', - 'position': {'dy': 20, 'dx': 10.0}, - 'id': 'text-1', - }, - 'payloadVersion': 1.0, - 'kind': 'text', - }; + group('separate remote read models', () { + final header = RemoteStrategyHeader.fromJson({ + 'publicId': 'strat-1', + 'name': 'Cloud', + 'mapData': 'ascent', + 'revision': 3, + 'createdAt': 1, + 'updatedAt': 2, + 'themeOverridePalette': {'base': '#111111'}, + }); + final page = RemotePage.fromJson({ + 'publicId': 'page-1', + 'strategyPublicId': 'strat-1', + 'name': 'Page 1', + 'sortIndex': 0, + 'isAttack': true, + 'revision': 2, + 'createdAt': 1, + 'updatedAt': 2, + }); + final content = RemotePageContent.fromJson({ + 'settings': {'agentSize': 35.0}, + 'revision': 7, + 'createdAt': 1, + 'updatedAt': 2, + }); - expect(cloudJsonEquivalent(left, right), isTrue); + test('shell carries descriptors but no page content', () { + final shell = RemoteStrategyShell(header: header, pages: [page]); + expect(shell.header.revision, 3); + expect(shell.pages.single.revision, 2); + expect( + shell.header.themeOverridePalette, containsPair('base', '#111111')); }); - }); - group('remote metadata payloads', () { - test('strategy headers and pages parse object metadata payloads', () { - final header = RemoteStrategyHeader.fromJson({ - 'publicId': 'strat-1', - 'name': 'Cloud', - 'mapData': 'ascent', - 'sequence': 1, - 'createdAt': 1, - 'updatedAt': 2, - 'themeOverridePalette': { - 'base': '#111111', - 'detail': '#222222', - 'highlight': '#333333', - }, - }); - final page = RemotePage.fromJson({ - 'publicId': 'page-1', - 'strategyPublicId': 'strat-1', - 'name': 'Page 1', - 'sortIndex': 0, - 'isAttack': true, - 'revision': 1, - 'settings': { - 'agentSize': 35.0, - 'abilitySize': 25.0, - 'useNeutralTeamColors': true, - }, - }); + test('editor carries exactly one active page body', () { + final editor = RemoteEditorSnapshot( + shell: RemoteStrategyShell(header: header, pages: [page]), + activePage: RemotePageSnapshot( + page: page, + content: content, + elements: const [], + lineups: const [], + assetsById: const {}, + ), + ); + expect( + editor.activePage!.content.settings, containsPair('agentSize', 35)); + expect(editor.elementsByPage.keys, ['page-1']); + }); - expect(header.themeOverridePalette, containsPair('base', '#111111')); - expect(page.settings, containsPair('useNeutralTeamColors', true)); + test('full snapshot groups every page for one-shot export', () { + const element = RemoteElement( + publicId: 'el-1', + strategyPublicId: 'strat-1', + pagePublicId: 'page-1', + elementType: 'text', + payload: {'kind': 'text', 'payloadVersion': 1, 'data': {}}, + sortIndex: 1, + revision: 1, + deleted: false, + ); + final grouped = RemoteFullStrategySnapshot.groupElementsByPage( + const [element], + ); + expect(grouped['page-1'], const [element]); }); }); group('RemoteImageAsset', () { - test('parses R2 metadata without treating URL as durable payload', () { + test('parses R2 metadata', () { final asset = RemoteImageAsset.fromJson({ 'publicId': 'asset-1', 'provider': 'r2', 'uploadStatus': 'active', 'fileExtension': '.png', - 'mimeType': 'image/png', - 'width': 1920, - 'height': 1080, 'byteSize': 42, 'uploadedAt': 1700000000000, 'url': 'https://media.example.com/asset-1.png', }); - - expect(asset.publicId, 'asset-1'); expect(asset.provider, 'r2'); - expect(asset.uploadStatus, 'active'); expect(asset.byteSize, 42); - expect( - asset.uploadedAt, DateTime.fromMillisecondsSinceEpoch(1700000000000)); - expect(asset.url, startsWith('https://media.example.com/')); - }); - - test('defaults legacy Convex storage rows to active Convex assets', () { - final asset = RemoteImageAsset.fromJson({ - 'publicId': 'asset-legacy', - 'fileExtension': '.jpg', - }); - - expect(asset.provider, 'convex'); - expect(asset.uploadStatus, 'active'); - expect(asset.fileExtension, '.jpg'); }); }); - group('CloudImageUploadIntent', () { - test('parses R2 upload response headers and expiration', () { - final intent = CloudImageUploadIntent.fromJson({ - 'provider': 'r2', - 'uploadId': 'upload-1', - 'objectKey': 'strategies/s/images/a.png', - 'uploadUrl': 'https://example.r2.cloudflarestorage.com/bucket/key', - 'requiredHeaders': {'Content-Type': 'image/png'}, - 'expiresAt': 1700000000000, - 'maxBytes': 1024, - }); - - expect(intent.provider, 'r2'); - expect(intent.uploadId, 'upload-1'); - expect(intent.objectKey, 'strategies/s/images/a.png'); - expect(intent.requiredHeaders['Content-Type'], 'image/png'); - expect( - intent.expiresAt, DateTime.fromMillisecondsSinceEpoch(1700000000000)); - expect(intent.maxBytes, 1024); - }); - }); - - group('RemoteStrategySnapshot helpers', () { - final header = RemoteStrategyHeader( - publicId: 'strat-1', - name: 'Original', - mapData: '{}', - sequence: 1, - createdAt: DateTime.fromMillisecondsSinceEpoch(1), - updatedAt: DateTime.fromMillisecondsSinceEpoch(2), - ); - const page1 = RemotePage( - publicId: 'page-1', - strategyPublicId: 'strat-1', - name: 'Page 1', - sortIndex: 0, - isAttack: true, - revision: 1, - ); - const page2 = RemotePage( - publicId: 'page-2', - strategyPublicId: 'strat-1', - name: 'Page 2', - sortIndex: 1, - isAttack: false, - revision: 1, - ); - const element = RemoteElement( - publicId: 'el-1', - strategyPublicId: 'strat-1', - pagePublicId: 'page-1', - elementType: 'text', - payload: { - 'kind': 'text', - 'payloadVersion': 1, - 'data': {}, - }, - sortIndex: 1, - revision: 1, - deleted: false, - ); - const deletedElement = RemoteElement( - publicId: 'el-2', - strategyPublicId: 'strat-1', - pagePublicId: 'page-1', - elementType: 'text', - payload: { - 'kind': 'text', - 'payloadVersion': 1, - 'data': {}, - }, - sortIndex: 0, - revision: 2, - deleted: true, - ); - const lineup = RemoteLineup( - publicId: 'lineup-1', - strategyPublicId: 'strat-1', - pagePublicId: 'page-2', - payload: { - 'kind': 'lineupGroup', - 'payloadVersion': 1, - 'data': {}, - }, - sortIndex: 0, - revision: 1, - deleted: false, - ); - - RemoteStrategySnapshot snapshot() => RemoteStrategySnapshot( - header: header, - pages: const [page1, page2], - elementsByPage: const { - 'page-1': [element], - }, - lineupsByPage: const { - 'page-2': [lineup], - }, - assetsById: const {}, - ); - - test('header update preserves pages assets elements and lineups', () { - final updated = snapshot().replaceHeader( - RemoteStrategyHeader( - publicId: 'strat-1', - name: 'Updated', - mapData: '{}', - sequence: 2, - createdAt: DateTime.fromMillisecondsSinceEpoch(1), - updatedAt: DateTime.fromMillisecondsSinceEpoch(3), - ), - ); - - expect(updated.header.name, 'Updated'); - expect(updated.pages, const [page1, page2]); - expect(updated.elementsByPage['page-1'], const [element]); - expect(updated.lineupsByPage['page-2'], const [lineup]); - }); - - test('pages update preserves unchanged page maps and prunes removed pages', - () { - final updated = snapshot().replacePages(const [page1]); - - expect(updated.pages, const [page1]); - expect(updated.elementsByPage.containsKey('page-1'), isTrue); - expect(updated.lineupsByPage.containsKey('page-2'), isFalse); - }); - - test('strategy-level elements are grouped by page and retain deletes', () { - final grouped = RemoteStrategySnapshot.groupElementsByPage( - const [element, deletedElement], - ); - - expect(grouped['page-1'], const [deletedElement, element]); - expect(grouped['page-1']!.first.deleted, isTrue); - }); - - test('strategy-level lineups are grouped by page', () { - final grouped = RemoteStrategySnapshot.groupLineupsByPage(const [lineup]); - - expect(grouped['page-2'], const [lineup]); + test('CloudImageUploadIntent parses upload headers', () { + final intent = CloudImageUploadIntent.fromJson({ + 'provider': 'r2', + 'uploadId': 'upload-1', + 'objectKey': 'strategies/s/images/a.png', + 'uploadUrl': 'https://example.invalid/key', + 'requiredHeaders': {'Content-Type': 'image/png'}, + 'expiresAt': 1700000000000, + 'maxBytes': 1024, }); + expect(intent.requiredHeaders['Content-Type'], 'image/png'); + expect(intent.maxBytes, 1024); }); } diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart index e8d9f650..29c88c85 100644 --- a/test/providers/auth_provider_test.dart +++ b/test/providers/auth_provider_test.dart @@ -2,18 +2,30 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/in_app_debug_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() { + appProviderContainer = ProviderContainer(); + }); + + tearDownAll(() { + appProviderContainer.dispose(); + }); + late FakeSupabaseApi supabaseApi; late FakeConvexApi convexApi; setUp(() { supabaseApi = FakeSupabaseApi(); convexApi = FakeConvexApi(); + appProviderContainer.read(inAppDebugProvider.notifier).clearLogs(); AuthProvider.debugSupabaseApi = supabaseApi; AuthProvider.debugConvexApi = convexApi; AuthProvider.debugConvexAuthReadyTimeout = const Duration(milliseconds: 50); @@ -127,6 +139,49 @@ void main() { expect(state.convexAuthStatus, ConvexAuthStatus.ready); }); + test('callback failure removes credentials from UI and diagnostics', + () async { + final callback = Uri.parse( + 'icarus://auth/callback?code=callback-code-secret' + '#access_token=access-secret&refresh_token=refresh-secret', + ); + supabaseApi.sessionFromUrlError = Exception( + 'Provider rejected $callback ' + '{"code_verifier":"verifier-secret"} ' + 'access_token%3Dencoded-secret%26token_type%3Dbearer', + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + final handled = + await container.read(authProvider.notifier).handleAuthCallbackUri( + callback, + source: 'test', + ); + + expect(handled, isTrue); + expect( + container.read(authProvider).errorMessage, + 'Failed to complete login. Please try again.', + ); + + final report = AppErrorReporter.buildClipboardReport( + appProviderContainer.read(inAppDebugProvider), + ); + expect(report, contains('Failed auth callback [test]')); + expect(report, contains('redacted')); + for (final credential in [ + 'callback-code-secret', + 'access-secret', + 'refresh-secret', + 'verifier-secret', + 'encoded-secret', + ]) { + expect(report, isNot(contains(credential))); + } + }); + test( 'existing session on startup waits for Convex auth readiness before ensuring user', () async { @@ -234,6 +289,7 @@ void main() { final state = container.read(authProvider); expect(state.isAuthenticated, isFalse); expect(state.isConvexUserReady, isFalse); + expect(state.errorMessage, isNull); expect(state.convexAuthStatus, ConvexAuthStatus.signedOut); }); @@ -452,6 +508,7 @@ class FakeSupabaseApi implements AuthProviderSupabaseApi { bool emitInitialSessionOnListen = false; bool emitSignedInEventOnPasswordSignIn = false; Session? sessionFromUrlSession; + Object? sessionFromUrlError; int getSessionFromUrlCalls = 0; final List> _controllers = >[]; @@ -475,6 +532,9 @@ class FakeSupabaseApi implements AuthProviderSupabaseApi { @override Future getSessionFromUrl(Uri uri) async { getSessionFromUrlCalls += 1; + if (sessionFromUrlError case final Object error?) { + throw error; + } if (sessionFromUrlSession case final Session session?) { currentSession = session; for (final controller in _controllers) { diff --git a/test/providers/cloud_migration_provider_test.dart b/test/providers/cloud_migration_provider_test.dart new file mode 100644 index 00000000..239dc376 --- /dev/null +++ b/test/providers/cloud_migration_provider_test.dart @@ -0,0 +1,131 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/cloud_migration_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; + +bool _adaptersRegistered = false; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDirectory; + late ProviderContainer container; + late FakeCloudMigrationApi api; + + setUp(() async { + tempDirectory = + await Directory.systemTemp.createTemp('icarus-cloud-migration-'); + Hive.init(tempDirectory.path); + if (!_adaptersRegistered) { + registerIcarusAdapters(Hive); + _adaptersRegistered = true; + } + await Hive.openBox(HiveBoxNames.foldersBox); + await Hive.openBox(HiveBoxNames.strategiesBox); + + api = FakeCloudMigrationApi(); + container = ProviderContainer( + overrides: [ + isCloudCollabEnabledProvider.overrideWithValue(true), + cloudMigrationApiProvider.overrideWithValue(api), + ], + ); + }); + + tearDown(() async { + container.dispose(); + await Hive.close(); + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + test('failed remote write stays incomplete and retries in the same session', + () async { + await Hive.box(HiveBoxNames.foldersBox).add( + Folder( + name: 'Retry me', + id: 'folder-1', + dateCreated: DateTime.utc(2026), + ), + ); + api.folderFailuresRemaining = 1; + + final notifier = container.read(cloudMigrationProvider.notifier); + await notifier.maybeMigrate(); + + expect(container.read(cloudMigrationProvider), isFalse); + expect(api.createFolderCalls, 1); + + await notifier.maybeMigrate(); + + expect(container.read(cloudMigrationProvider), isTrue); + expect(api.createFolderCalls, 2); + + await notifier.maybeMigrate(); + expect(api.createFolderCalls, 2); + }); +} + +class FakeCloudMigrationApi implements CloudMigrationApi { + int folderFailuresRemaining = 0; + int createFolderCalls = 0; + + @override + Future createFolder({ + required String publicId, + required String name, + String? parentFolderPublicId, + int? iconId, + }) async { + createFolderCalls += 1; + if (folderFailuresRemaining > 0) { + folderFailuresRemaining -= 1; + throw StateError('temporary cloud failure'); + } + } + + @override + Future createStrategyWithInitialPage({ + required String publicId, + required String name, + required String mapData, + required String initialPagePublicId, + required String initialPageName, + required bool initialPageIsAttack, + required Map initialPageSettings, + String? folderPublicId, + String? themeProfileId, + Map? themeOverridePalette, + }) async {} + + @override + Future addPage({ + required String strategyPublicId, + required String pagePublicId, + required String name, + required int sortIndex, + required bool isAttack, + required Map settings, + required int expectedRevision, + }) async {} + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) async { + return [ + for (final op in ops) OpAck(opId: op.opId, status: 'ack'), + ]; + } +} diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index 18592d54..b45114e2 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -1,168 +1,432 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; void main() { - group('Entity sync keys', () { - test('round trips page ids that contain delimiters', () { - const pageId = 'strategy-1:page:1'; - const elementId = 'element-1'; - const lineupId = 'lineup-1'; - const strategyKey = EntitySyncKey.strategy(); - const pageKey = EntitySyncKey.pageSettings(pageId); - const elementKey = EntitySyncKey.element(pageId, elementId); - const lineupKey = EntitySyncKey.lineup(pageId, lineupId); - - expect(strategyKey.kind, EntitySyncKeyKind.strategy); - expect(strategyKey.overlayType, isNull); - expect(pageKey.kind, EntitySyncKeyKind.pageSettings); - expect(pageKey.overlayType, ActivePageOverlayEntityType.pageSettings); - expect(pageKey.pageId, pageId); - expect(elementKey.pageId, pageId); - expect(elementKey.kind, EntitySyncKeyKind.element); - expect(elementKey.overlayType, ActivePageOverlayEntityType.element); - expect(elementKey.entityId, elementId); - expect(lineupKey.pageId, pageId); - expect(lineupKey.kind, EntitySyncKeyKind.lineup); - expect(lineupKey.overlayType, ActivePageOverlayEntityType.lineup); - expect(lineupKey.entityId, lineupId); + group('Entity sync revision domains', () { + test('page descriptor and page content cannot coalesce together', () { + const descriptor = EntitySyncKey.pageDescriptor('page:1'); + const content = EntitySyncKey.pageContent('page:1'); + expect(descriptor, isNot(content)); + expect(descriptor.kind, EntitySyncKeyKind.pageDescriptor); + expect(content.kind, EntitySyncKeyKind.pageContent); + expect( + descriptor.overlayType, + ActivePageOverlayEntityType.pageDescriptor, + ); + expect(content.overlayType, ActivePageOverlayEntityType.pageContent); + expect(descriptor.toString(), 'page:page%3A1:descriptor'); }); }); - group('StrategyOpQueueNotifier coalescing', () { - late ProviderContainer container; - late StrategyOpQueueNotifier notifier; + group('durable strategy outbox', () { + late MemoryDurableStrategyOutboxStore store; + ProviderContainer? container; - setUp(() { - container = ProviderContainer(); - notifier = container.read(strategyOpQueueProvider.notifier); - notifier.setActiveStrategy('strategy-1'); - }); + StrategyOpQueueNotifier start({ + String? accountId = 'account-a', + String? strategyId = 'strategy-1', + }) { + container?.dispose(); + container = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + ]); + container! + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final notifier = container!.read(strategyOpQueueProvider.notifier); + notifier.setActiveStrategy(strategyId, accountId: accountId); + return notifier; + } + + StrategyOp elementOp({ + String opId = 'op-1', + String elementId = 'element-1', + String value = 'a', + StrategyOpKind kind = StrategyOpKind.patch, + }) { + return StrategyOp( + opId: opId, + kind: kind, + entityType: StrategyOpEntityType.element, + entityPublicId: elementId, + pagePublicId: 'page-1', + payload: {'value': value}, + sortIndex: 0, + expectedRevision: kind == StrategyOpKind.add ? null : 1, + ); + } + + DurableOutboxRecord record({ + required DurableOutboxStatus status, + String accountId = 'account-a', + String strategyId = 'strategy-1', + String opId = 'op-1', + int attempts = 0, + }) { + final op = elementOp(opId: opId); + return DurableOutboxRecord( + accountId: accountId, + strategyPublicId: strategyId, + entityKey: const EntitySyncKey.element('page-1', 'element-1'), + pending: PendingOp( + op: op, + clientId: 'stable-client', + attempts: attempts, + ), + status: status, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + } - tearDown(() { - container.dispose(); + setUp(() => store = MemoryDurableStrategyOutboxStore()); + tearDown(() => container?.dispose()); + + test('restart after enqueue and before send restores the intent', () async { + final notifier = start(); + await notifier.enqueue(elementOp(), flushImmediately: false); + expect(store.values, hasLength(1)); + + start(); + final restored = container!.read(strategyOpQueueProvider); + expect(restored.pending, hasLength(1)); + expect(restored.pending.single.op.opId, 'op-1'); + expect(restored.pending.single.clientId, isNotEmpty); }); - test('coalesces add followed by patch into one add op', () { - notifier.enqueue( + test('page descriptor mutation survives restart in the durable outbox', + () async { + final notifier = start(); + await notifier.enqueue( const StrategyOp( - opId: 'add-1', + opId: 'add-page', kind: StrategyOpKind.add, - entityType: StrategyOpEntityType.element, - entityPublicId: 'element-1', - pagePublicId: 'page-1', - payload: '{"value":"a"}', - sortIndex: 0, + entityType: StrategyOpEntityType.page, + entityPublicId: 'page-2', + payload: { + 'name': 'Execute', + 'isAttack': true, + 'settings': { + 'agentSize': 1.0, + 'abilitySize': 1.0, + 'useNeutralTeamColors': false, + }, + }, + sortIndex: 1, + expectedRevision: 4, ), + flushImmediately: false, ); - notifier.enqueue( - const StrategyOp( - opId: 'patch-1', - kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.element, - entityPublicId: 'element-1', - pagePublicId: 'page-1', - payload: '{"value":"b"}', - sortIndex: 2, - ), + expect(store.values, hasLength(1)); + + start(); + + final restored = container!.read(strategyOpQueueProvider); + expect(restored.pending, hasLength(1)); + final intent = restored.queuedByEntityKey.entries.single; + expect(intent.key, const EntitySyncKey.pageDescriptor('page-2')); + expect(intent.value.pending.op.opId, 'add-page'); + expect(intent.value.pending.op.kind, StrategyOpKind.add); + expect(intent.value.pending.op.expectedRevision, 4); + }); + + test('restart while in flight replays the same event key', () async { + final saved = record(status: DurableOutboxStatus.inFlight); + await store.put(saved); + start(); + + final restored = container!.read(strategyOpQueueProvider); + expect(restored.queuedByEntityKey, hasLength(1)); + expect(restored.inFlightByEntityKey, isEmpty); + expect(restored.pending.single.op.opId, 'op-1'); + expect(restored.pending.single.clientId, 'stable-client'); + }); + + test('server-accepted crash window retains the exact op for replay', + () async { + final saved = + record(status: DurableOutboxStatus.inFlight, opId: 'accepted'); + await store.put(saved); + start(); + final pending = container!.read(strategyOpQueueProvider).pending.single; + expect(pending.op.opId, 'accepted'); + expect(pending.clientId, 'stable-client'); + }); + + test('strategy switch retains another page pending op', () async { + final notifier = start(); + await notifier.enqueue(elementOp()); + notifier.setActiveStrategy('strategy-2', accountId: 'account-a'); + expect(container!.read(strategyOpQueueProvider).pending, isEmpty); + notifier.setActiveStrategy('strategy-1', accountId: 'account-a'); + expect(container!.read(strategyOpQueueProvider).pending, hasLength(1)); + }); + + test('sign-out and same-account recovery restores work', () async { + final notifier = start(); + await notifier.enqueue(elementOp()); + notifier.setActiveStrategy(null, accountId: null); + expect(container!.read(strategyOpQueueProvider).pending, isEmpty); + notifier.setActiveStrategy('strategy-1', accountId: 'account-a'); + expect(container!.read(strategyOpQueueProvider).pending, hasLength(1)); + }); + + test('different account cannot see or submit saved work', () async { + final notifier = start(); + await notifier.enqueue(elementOp()); + notifier.setActiveStrategy('strategy-1', accountId: 'account-b'); + expect(container!.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, hasLength(1)); + }); + + test('retry exhaustion is paused and manual retry keeps identity', + () async { + final saved = record( + status: DurableOutboxStatus.paused, + attempts: 8, ); + await store.put(saved); + final notifier = start(); + expect(container!.read(strategyOpQueueProvider).needsAttention, isTrue); + expect(container!.read(strategyOpQueueProvider).pausedByEntityKey, + hasLength(1)); - final pending = container.read(strategyOpQueueProvider).pending; - expect(pending, hasLength(1)); - expect(pending.single.op.kind, StrategyOpKind.add); - expect(pending.single.op.payload, '{"value":"b"}'); - expect(pending.single.op.sortIndex, 2); + await notifier.retryPaused(flushImmediately: false); + final retried = container!.read(strategyOpQueueProvider); + expect(retried.pausedByEntityKey, isEmpty); + expect(retried.queuedByEntityKey, hasLength(1)); + expect(retried.pending.single.op.opId, 'op-1'); + expect(retried.pending.single.clientId, 'stable-client'); }); - test('coalesces repeated patches to the latest payload', () { - notifier.enqueue( - const StrategyOp( - opId: 'patch-1', - kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.lineup, - entityPublicId: 'lineup-1', - pagePublicId: 'page-1', - payload: '{"value":"a"}', - sortIndex: 0, - ), + test('corrupt persisted record produces attention and remains present', () { + store.values['broken'] = {'outboxVersion': 999}; + start(); + final loaded = container!.read(strategyOpQueueProvider); + expect(loaded.durableLoaded, isTrue); + expect(loaded.needsAttention, isTrue); + expect(loaded.loadIssues.single.storageKey, 'broken'); + expect(store.values, contains('broken')); + }); + + test('reconciliation replaces rejected immutable opId before removal', + () async { + final saved = record(status: DurableOutboxStatus.attention); + await store.put(saved); + final notifier = start(); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + const EntitySyncKey.element('page-1', 'element-1'): + elementOp(opId: 'replacement', value: 'new'), + }, + flushImmediately: false, ); - notifier.enqueue( - const StrategyOp( - opId: 'patch-2', - kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.lineup, - entityPublicId: 'lineup-1', - pagePublicId: 'page-1', - payload: '{"value":"b"}', - sortIndex: 1, - ), + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.queuedByEntityKey.values.single.pending.op.opId, + 'replacement'); + expect( + (store.values.values.single as Map)['opId'], + 'replacement', ); + }); - final pending = container.read(strategyOpQueueProvider).pending; - expect(pending, hasLength(1)); - expect(pending.single.op.kind, StrategyOpKind.patch); - expect(pending.single.op.payload, '{"value":"b"}'); - expect(pending.single.op.sortIndex, 1); + test('ordinary reconciliation does not discard attention work', () async { + final saved = record(status: DurableOutboxStatus.attention); + await store.put(saved); + final notifier = start(); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: const {}, + flushImmediately: false, + ); + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, hasLength(1)); + expect(store.values, hasLength(1)); }); - test('removes add when followed by delete for same entity', () { - notifier.enqueue( - const StrategyOp( - opId: 'add-1', - kind: StrategyOpKind.add, - entityType: StrategyOpEntityType.element, - entityPublicId: 'element-1', - pagePublicId: 'page-1', - payload: '{"value":"a"}', - sortIndex: 0, - ), + test('explicit rejected retry uses durable latest server revision', + () async { + final saved = record(status: DurableOutboxStatus.attention).copyWith( + latestServerRevision: 7, ); - notifier.enqueue( - const StrategyOp( - opId: 'delete-1', - kind: StrategyOpKind.delete, - entityType: StrategyOpEntityType.element, - entityPublicId: 'element-1', - pagePublicId: 'page-1', - ), + await store.put(saved); + final notifier = start(); + await notifier.retryRejected(flushImmediately: false); + + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.queuedByEntityKey, hasLength(1)); + final retried = current.queuedByEntityKey.values.single.pending; + expect(retried.op.opId, isNot('op-1')); + expect(retried.op.expectedRevision, 7); + expect(retried.attempts, 0); + final durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), ); + expect(durable.status, DurableOutboxStatus.queued); + expect(durable.latestServerRevision, isNull); + expect(durable.pending.op.opId, retried.op.opId); + }); + + test('explicit rejected retry falls back to the original revision', + () async { + await store.put(record(status: DurableOutboxStatus.attention)); + final notifier = start(); + await notifier.retryRejected(flushImmediately: false); - final pending = container.read(strategyOpQueueProvider).pending; - expect(pending, isEmpty); + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + final retried = current.queuedByEntityKey.values.single.pending; + expect(retried.op.opId, isNot('op-1')); + expect(retried.op.expectedRevision, 1); }); - test('preserves unrelated pending ops', () { - notifier.enqueue( - const StrategyOp( - opId: 'patch-1', - kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.element, - entityPublicId: 'element-1', - pagePublicId: 'page-1', - payload: '{"value":"a"}', - sortIndex: 0, + test('explicit rejected retry preserves a tombstone restore add', () async { + const key = EntitySyncKey.element('page-1', 'element-1'); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: key, + pending: const PendingOp( + op: StrategyOp( + opId: 'restore-op', + kind: StrategyOpKind.add, + entityType: StrategyOpEntityType.element, + entityPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'restore me'}, + expectedRevision: 2, + ), + clientId: 'stable-client', ), - ); - notifier.enqueue( - const StrategyOp( - opId: 'patch-2', - kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.element, - entityPublicId: 'element-2', - pagePublicId: 'page-1', - payload: '{"value":"b"}', - sortIndex: 1, + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + lastError: 'revision_mismatch', + latestServerRevision: 3, + )); + final notifier = start(); + await notifier.retryRejected(flushImmediately: false); + + final retried = container! + .read(strategyOpQueueProvider) + .queuedByEntityKey[key]! + .pending + .op; + expect(retried.opId, isNot('restore-op')); + expect(retried.kind, StrategyOpKind.add); + expect(retried.expectedRevision, 3); + }); + + test('explicit rejected retry converts an active add collision to patch', + () async { + const key = EntitySyncKey.element('page-1', 'element-1'); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: key, + pending: PendingOp( + op: elementOp(kind: StrategyOpKind.add), + clientId: 'stable-client', ), - ); + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + lastError: 'already_exists', + latestServerRevision: 2, + )); + final notifier = start(); + await notifier.retryRejected(flushImmediately: false); - final pending = container.read(strategyOpQueueProvider).pending; - expect(pending, hasLength(2)); - expect( - pending.map((op) => op.op.entityPublicId), - ['element-1', 'element-2'], + final retried = container! + .read(strategyOpQueueProvider) + .queuedByEntityKey[key]! + .pending + .op; + expect(retried.kind, StrategyOpKind.patch); + expect(retried.expectedRevision, 2); + }); + + test('explicit rejected retry explains when no revision is available', + () async { + final saved = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: const EntitySyncKey.element('page-1', 'element-1'), + pending: PendingOp( + op: elementOp(kind: StrategyOpKind.add), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), ); + await store.put(saved); + final notifier = start(); + await notifier.retryRejected(flushImmediately: false); + + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, hasLength(1)); + expect(current.queuedByEntityKey, isEmpty); + expect(current.lastError, contains('cannot be retried automatically')); + }); + + test('coalesces add and patch only after durable replacement', () async { + final notifier = start(); + await notifier.enqueue(elementOp(kind: StrategyOpKind.add)); + await notifier.enqueue(elementOp(opId: 'patch', value: 'b')); + final pending = container!.read(strategyOpQueueProvider).pending.single; + expect(pending.op.kind, StrategyOpKind.add); + expect(pending.op.opId, 'op-1'); + expect(pending.op.payload, {'value': 'b'}); + expect(store.values, hasLength(1)); }); }); + + test('provider state waits for durable persistence before showing Syncing', + () async { + final store = _BlockingStore(); + final container = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + ]); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final enqueue = notifier.enqueue(const StrategyOp( + opId: 'op-1', + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.element, + entityPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedRevision: 1, + )); + await Future.delayed(Duration.zero); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + store.allowWrite.complete(); + await enqueue; + expect(container.read(strategyOpQueueProvider).pending, hasLength(1)); + }); +} + +class _BlockingStore extends MemoryDurableStrategyOutboxStore { + final allowWrite = Completer(); + + @override + Future put(DurableOutboxRecord record) async { + await allowWrite.future; + await super.put(record); + } } diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 07c5e2e4..66fc16d2 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -5,7 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/collab/collab_models.dart'; -import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/line_provider.dart'; @@ -13,1497 +13,962 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/hive/hive_registration.dart'; -import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; -import 'package:icarus/providers/agent_provider.dart'; -import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/transition_provider.dart' - as overlay_transition; + hide PageTransitionState; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; -class _StaticStrategyProvider extends StrategyProvider { - _StaticStrategyProvider(this.initialState); - - final StrategyState initialState; - - @override - StrategyState build() => initialState; -} - -class _FakeRemoteStrategySnapshotNotifier - extends RemoteStrategySnapshotNotifier { - _FakeRemoteStrategySnapshotNotifier(this.initialSnapshot); +class _FakeRemoteEditorNotifier extends RemoteEditorSnapshotNotifier { + _FakeRemoteEditorNotifier( + this.initialSnapshot, { + Map? pageCatalog, + }) : pageCatalog = Map.from( + pageCatalog ?? + { + if (initialSnapshot.activePage != null) + initialSnapshot.activePage!.page.publicId: + initialSnapshot.activePage!, + }, + ); - RemoteStrategySnapshot? initialSnapshot; + RemoteEditorSnapshot initialSnapshot; + final Map pageCatalog; int refreshCount = 0; + final List selectedPageIds = []; + String? failingPageId; @override - Future build() async => initialSnapshot; + Future build() async => initialSnapshot; - void setSnapshot(RemoteStrategySnapshot snapshot) { + void setSnapshot(RemoteEditorSnapshot snapshot) { initialSnapshot = snapshot; + final active = snapshot.activePage; + if (active != null) pageCatalog[active.page.publicId] = active; state = AsyncData(snapshot); } + @override + Future setActivePage(String? pagePublicId) async { + selectedPageIds.add(pagePublicId); + if (pagePublicId == failingPageId) { + throw StateError('Failed to load $pagePublicId'); + } + final current = state.valueOrNull ?? initialSnapshot; + final page = pagePublicId == null ? null : pageCatalog[pagePublicId]; + state = AsyncData(RemoteEditorSnapshot( + shell: current.shell, + activePage: page, + )); + return page; + } + @override Future refresh() async { - refreshCount++; + refreshCount += 1; state = AsyncData(initialSnapshot); } } class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { - _FakeStrategyOpQueueNotifier(this.strategyPublicId); + _FakeStrategyOpQueueNotifier({this.blockFlush = false}); - final String? strategyPublicId; - int enqueueAllCount = 0; - int syncDesiredOpsForPageCount = 0; + final bool blockFlush; int flushNowCount = 0; - final List enqueuedOps = []; @override - StrategyOpQueueState build() { - return StrategyOpQueueState( - strategyPublicId: strategyPublicId, - clientId: 'test-client', - ); - } + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'test-client', + durableLoaded: true, + ); @override - void setActiveStrategy(String? strategyPublicId) { - state = state.copyWith( - strategyPublicId: strategyPublicId, - queuedByEntityKey: const {}, - inFlightByEntityKey: const {}, - lastAcks: const [], - lastAckBatch: const [], - clearError: true, - ); - } + void setActiveStrategy( + String? strategyPublicId, { + required String? accountId, + }) {} @override - void enqueueAll(Iterable ops, {bool flushImmediately = false}) { - final collected = ops.toList(growable: false); - enqueueAllCount++; - enqueuedOps.addAll(collected); - final queued = {}; - for (final op in collected) { - final key = EntitySyncKey.forStrategyOp(op); - if (key == null) { - continue; - } - queued[key] = QueuedEntityIntent( - entityKey: key, - pending: PendingOp(op: op, clientId: state.clientId ?? 'test-client'), + Future syncDesiredGenericOp({ + required EntitySyncKey entityKey, + required StrategyOp? desiredOp, + bool flushImmediately = false, + }) async { + final queued = Map.from( + state.queuedByEntityKey, + ); + if (desiredOp == null) { + queued.remove(entityKey); + } else { + queued[entityKey] = QueuedEntityIntent( + entityKey: entityKey, + pending: PendingOp(op: desiredOp, clientId: 'test-client'), ); } - state = state.copyWith( - queuedByEntityKey: queued, - inFlightByEntityKey: const {}, - clearError: true, - ); - if (flushImmediately) { - flushNow(); - } + state = state.copyWith(queuedByEntityKey: queued); } @override - void syncDesiredOpsForPage({ + Future syncDesiredOpsForPage({ required String pageId, required Map desiredOpsByEntityKey, bool clearMissing = true, bool flushImmediately = false, - }) { - syncDesiredOpsForPageCount++; - super.syncDesiredOpsForPage( - pageId: pageId, - desiredOpsByEntityKey: desiredOpsByEntityKey, - clearMissing: clearMissing, - flushImmediately: flushImmediately, + }) async { + final queued = Map.from( + state.queuedByEntityKey, ); + if (clearMissing) { + queued.removeWhere((key, _) => + key.pageId == pageId && !desiredOpsByEntityKey.containsKey(key)); + } + for (final entry in desiredOpsByEntityKey.entries) { + queued[entry.key] = QueuedEntityIntent( + entityKey: entry.key, + pending: PendingOp(op: entry.value, clientId: 'test-client'), + ); + } + state = state.copyWith(queuedByEntityKey: queued); } @override Future flushNow() async { - flushNowCount++; - state = state.copyWith( - queuedByEntityKey: const {}, - inFlightByEntityKey: const {}, - isFlushing: false, - lastFlushAt: DateTime.now(), - ); + flushNowCount += 1; + if (blockFlush) await Completer().future; } - void emitAcks(List acks, [List? ackBatch]) { + void reject(StrategyOp op) { + final key = EntitySyncKey.forStrategyOp(op)!; + final pending = PendingOp(op: op, clientId: 'test-client'); + final ack = OpAck( + opId: op.opId, + status: 'reject', + reason: 'revision_mismatch', + latestRevision: 2, + ); state = state.copyWith( - lastAcks: acks, - lastAckBatch: ackBatch ?? const [], + queuedByEntityKey: const {}, + attentionByEntityKey: { + key: QueuedEntityIntent(entityKey: key, pending: pending), + }, + isFlushing: false, + lastError: 'Some saved work needs attention.', + lastAcks: [ack], + lastAckBatch: [ + AckedEntityIntent(entityKey: key, op: op, ack: ack), + ], ); } } -Future> _openStrategyBox(String prefix) async { - const abilityInfoAdapterTypeId = 9; - final tempDir = await Directory.systemTemp.createTemp(prefix); - Hive.init(tempDir.path); - if (!Hive.isAdapterRegistered(abilityInfoAdapterTypeId)) { - registerIcarusAdapters(Hive); - } - - final strategyBox = - await Hive.openBox(HiveBoxNames.strategiesBox); - addTearDown(() async { - await Hive.close(); - await tempDir.delete(recursive: true); - }); - return strategyBox; -} - -RemoteStrategySnapshot _cloudSnapshot({ - required String strategyId, - required int sequence, - required List pages, - Map> elementsByPage = const {}, - Map> lineupsByPage = const {}, -}) { - final now = DateTime.utc(2026, 1, 1); - return RemoteStrategySnapshot( - header: RemoteStrategyHeader( - publicId: strategyId, - name: 'Cloud Strategy', - mapData: Maps.mapNames[MapValue.ascent]!, - sequence: sequence, - createdAt: now, - updatedAt: now, - ), - pages: pages, - elementsByPage: elementsByPage, - lineupsByPage: lineupsByPage, - assetsById: const {}, - ); +Future _settle() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); } -RemotePage _remotePage({ - required String strategyId, - required String pageId, - required int sortIndex, -}) { +RemotePage _page(String id, int index, {int revision = 1, String? name}) { + final now = DateTime.utc(2026); return RemotePage( - publicId: pageId, - strategyPublicId: strategyId, - name: 'Page $sortIndex', - sortIndex: sortIndex, + publicId: id, + strategyPublicId: 'cloud-strategy', + name: name ?? 'Page ${index + 1}', + sortIndex: index, isAttack: true, - revision: 1, + revision: revision, + createdAt: now, + updatedAt: now, ); } -RemoteElement _remoteText({ - required String strategyId, - required String pageId, - required String elementId, - required String text, - int sortIndex = 0, +RemoteElement _textElement( + String pageId, + String id, + String value, { + int revision = 1, + bool deleted = false, }) { - final placedText = PlacedText( - id: elementId, - position: const Offset(10, 20), - )..text = text; - final payload = Map.from(placedText.toJson()) - ..putIfAbsent('elementType', () => 'text'); + final text = PlacedText(id: id, position: const Offset(10, 20))..text = value; + final payload = Map.from(text.toJson()) + ..['elementType'] = 'text'; return RemoteElement( - publicId: elementId, - strategyPublicId: strategyId, + publicId: id, + strategyPublicId: 'cloud-strategy', pagePublicId: pageId, elementType: 'text', payload: cloudElementPayload(kind: 'text', data: payload), - sortIndex: sortIndex, - revision: 1, - deleted: false, + sortIndex: 0, + revision: revision, + deleted: deleted, ); } -RemoteLineup _remoteLineup({ - required String strategyId, - required String pageId, - required String lineupId, - required String notes, - int sortIndex = 0, +RemotePageSnapshot _pageSnapshot( + RemotePage page, { + String? text, + int contentRevision = 1, + List? elements, + List lineups = const [], }) { - final group = LineUpGroup( - id: lineupId, - agent: PlacedAgent( - id: '$lineupId-agent', - type: AgentType.jett, - position: const Offset(10, 20), - lineUpID: lineupId, + final now = DateTime.utc(2026); + return RemotePageSnapshot( + page: page, + content: RemotePageContent( + settings: const {}, + revision: contentRevision, + createdAt: now, + updatedAt: now, ), - items: [ - LineUpItem( - id: '$lineupId-item', - ability: PlacedAbility( - id: '$lineupId-ability', - data: AgentData.agents[AgentType.jett]!.abilities.first, - position: const Offset(30, 40), - lineUpID: lineupId, - ), - youtubeLink: '', - images: const [], - notes: notes, - ), - ], + elements: elements ?? + (text == null + ? const [] + : [_textElement(page.publicId, 'text-${page.publicId}', text)]), + lineups: lineups, + assetsById: const {}, ); +} + +RemoteLineup _lineup(String pageId, String id) { return RemoteLineup( - publicId: lineupId, - strategyPublicId: strategyId, + publicId: id, + strategyPublicId: 'cloud-strategy', pagePublicId: pageId, - payload: cloudLineupGroupPayload(group.toJson()), - sortIndex: sortIndex, + payload: { + 'kind': 'lineupGroup', + 'payloadVersion': 1, + 'data': { + 'id': id, + 'agent': { + 'id': 'agent-$id', + 'isDeleted': false, + 'position': {'dx': 10, 'dy': 20}, + 'type': 'sova', + 'isAlly': true, + 'state': 'none', + 'kind': 'plain', + 'lineUpID': id, + }, + 'items': [ + { + 'id': 'item-$id', + 'ability': { + 'id': 'ability-$id', + 'isDeleted': false, + 'data': {'type': 'sova', 'index': 2}, + 'position': {'dx': 30, 'dy': 40}, + 'isAlly': true, + 'rotation': 0, + 'length': 0, + 'lineUpID': id, + 'visualState': { + 'showRangeOutline': true, + 'showRangeFill': true, + 'showInnerOutline': true, + 'showInnerFill': true, + }, + 'armLengthsMeters': [10, 10, 10, 10], + }, + 'youtubeLink': '', + 'notes': 'remote lineup', + 'images': [], + }, + ], + }, + }, + sortIndex: 0, revision: 1, deleted: false, ); } -StrategyData _localStrategy({ - required String strategyId, - required String firstText, - required String secondText, +RemoteEditorSnapshot _editorSnapshot({ + required List pages, + required RemotePageSnapshot activePage, + int shellRevision = 1, + String? mapData, + String? themeProfileId, }) { - final pageOne = StrategyPage( - id: 'page-1', - name: 'Page 1', - drawingData: const [], - agentData: const [], - abilityData: const [], - textData: [ - PlacedText(id: 'text-1', position: const Offset(10, 20)) - ..text = firstText, - ], - imageData: const [], - utilityData: const [], - sortIndex: 0, - isAttack: true, - settings: StrategySettings(), - ); - final pageTwo = StrategyPage( - id: 'page-2', - name: 'Page 2', - drawingData: const [], - agentData: const [], - abilityData: const [], - textData: [ - PlacedText(id: 'text-2', position: const Offset(30, 40)) - ..text = secondText, - ], - imageData: const [], - utilityData: const [], - sortIndex: 1, - isAttack: true, - settings: StrategySettings(), - ); - - return StrategyData( - id: strategyId, - name: 'Local Strategy', - mapData: MapValue.ascent, - versionNumber: 1, - lastEdited: DateTime.utc(2026, 1, 1), - folderID: null, - pages: [pageOne, pageTwo], + final now = DateTime.utc(2026); + return RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'cloud-strategy', + name: 'Cloud Strategy', + mapData: mapData ?? Maps.mapNames[MapValue.ascent]!, + revision: shellRevision, + createdAt: now, + updatedAt: now, + themeProfileId: themeProfileId, + ), + pages: pages, + ), + activePage: activePage, ); } -Future _settle() async { - await Future.delayed(Duration.zero); - await Future.delayed(Duration.zero); -} - Future _cloudContainer({ - required StrategyState strategyState, - required _FakeRemoteStrategySnapshotNotifier remoteNotifier, - required _FakeStrategyOpQueueNotifier queueNotifier, + required _FakeRemoteEditorNotifier remote, + required _FakeStrategyOpQueueNotifier queue, }) async { - final container = ProviderContainer( - overrides: [ - remoteStrategySnapshotProvider.overrideWith(() => remoteNotifier), - strategyOpQueueProvider.overrideWith(() => queueNotifier), - ], - ); + final container = ProviderContainer(overrides: [ + remoteEditorSnapshotProvider.overrideWith(() => remote), + strategyOpQueueProvider.overrideWith(() => queue), + ]); addTearDown(container.dispose); - container.read(strategyProvider.notifier).setFromState(strategyState); + container.read(strategyProvider.notifier).setFromState(const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + )); container.listen(strategyPageSessionProvider, (_, __) {}); - await container.read(remoteStrategySnapshotProvider.future); + await container.read(remoteEditorSnapshotProvider.future); return container; } +Future> _openStrategyBox() async { + final temp = await Directory.systemTemp.createTemp('icarus-page-session-'); + Hive.init(temp.path); + if (!Hive.isAdapterRegistered(9)) registerIcarusAdapters(Hive); + final box = await Hive.openBox(HiveBoxNames.strategiesBox); + addTearDown(() async { + await Hive.close(); + await temp.delete(recursive: true); + }); + return box; +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => CoordinateSystem(playAreaSize: const Size(1920, 1080))); - setUpAll(() { - CoordinateSystem(playAreaSize: const Size(1920, 1080)); - }); - - test('remote snapshot reapply does not flush current cloud page', () async { - const strategyId = 'cloud-strategy'; - final pageOne = - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); - final initialSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'before') - ], - }, + test('cloud strategy metadata patch carries the remote shell revision', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + shellRevision: 17, + mapData: Maps.mapNames[MapValue.haven], + themeProfileId: 'remote-theme', + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), ); - final updatedSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'after') - ], - }, + + await container + .read(strategyProvider.notifier) + .notifyCloudStrategyMutation(); + + final op = container.read(strategyOpQueueProvider).pending.single.op; + expect(op.entityType, StrategyOpEntityType.strategy); + expect(op.expectedRevision, 17); + expect(op.toConvexJson()['expectedRevision'], 17); + expect( + op.payload, + containsPair('mapData', Maps.mapNames[MapValue.ascent]), ); + expect(op.payload, containsPair('clearThemeProfileId', true)); + }); - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(initialSnapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + test('cloud page reorder is persisted as a page descriptor op', () async { + final first = _page('page-1', 0); + final second = _page('page-2', 1); + final queue = _FakeStrategyOpQueueNotifier(); final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [first, second], + activePage: _pageSnapshot(first), + shellRevision: 17, + )), + queue: queue, ); - await container - .read(strategyPageSessionProvider.notifier) - .initializeForStrategy( - strategyId: strategyId, - source: StrategySource.cloud, - selectFirstPageIfNeeded: true, - ); - - container.read(textProvider.notifier).fromHive([ - PlacedText(id: 'local-text', position: const Offset(50, 60)) - ..text = 'local-only', - ]); - remoteNotifier.setSnapshot(updatedSnapshot); - await _settle(); + await container.read(strategyProvider.notifier).reorderPage(0, 2); - expect(queueNotifier.enqueueAllCount, 0); - expect(queueNotifier.flushNowCount, 0); - expect(container.read(textProvider).single.text, 'after'); + final intent = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .single; + final pending = intent.value.pending; + expect(pending.op.entityType, StrategyOpEntityType.page); + expect(pending.op.kind, StrategyOpKind.reorder); + expect(pending.op.entityPublicId, 'page-1'); + expect(pending.op.sortIndex, 1); + expect(pending.op.expectedRevision, 17); + expect(intent.key, const EntitySyncKey.pageDescriptor('page-1')); + expect(queue.flushNowCount, 1); }); - test('late active-page elements rehydrate after header sequence advance', - () async { - const strategyId = 'cloud-strategy'; - final pageOne = - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); - final beforeSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'before', - ), - ], - }, - ); - final headerFirstSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'before', - ), - ], - }, + test('cloud page add is persisted with its descriptor and content', () async { + final page = _page('page-1', 0); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + shellRevision: 8, + )), + queue: queue, ); - final elementsArrivedSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'after', - ), - ], - }, + + await container.read(strategyProvider.notifier).addPage('Execute'); + + final intent = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .single; + final pending = intent.value.pending; + expect(pending.op.entityType, StrategyOpEntityType.page); + expect(pending.op.kind, StrategyOpKind.add); + expect(pending.op.entityPublicId, isNotEmpty); + expect(pending.op.sortIndex, 1); + expect(pending.op.expectedRevision, 8); + expect(pending.op.payload, { + 'name': 'Execute', + 'isAttack': true, + 'settings': container.read(strategySettingsProvider).toJson(), + }); + expect( + intent.key, + EntitySyncKey.pageDescriptor(pending.op.entityPublicId!), ); + expect(queue.flushNowCount, 1); + }); - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(beforeSnapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + test('cloud page rename is persisted with the page revision', () async { + final page = _page('page-1', 0, revision: 6); + final queue = _FakeStrategyOpQueueNotifier(); final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )), + queue: queue, ); - await container - .read(strategyPageSessionProvider.notifier) - .initializeForStrategy( - strategyId: strategyId, - source: StrategySource.cloud, - selectFirstPageIfNeeded: true, - ); - remoteNotifier.setSnapshot(headerFirstSnapshot); - await _settle(); - expect(container.read(textProvider).single.text, 'before'); - - remoteNotifier.setSnapshot(elementsArrivedSnapshot); - await _settle(); + await container.read(strategyProvider.notifier).renamePage( + 'page-1', + ' Retake ', + ); - expect(container.read(textProvider).single.text, 'after'); - expect(queueNotifier.enqueueAllCount, 0); - expect(queueNotifier.flushNowCount, 0); + final intent = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .single; + final pending = intent.value.pending; + expect(pending.op.entityType, StrategyOpEntityType.page); + expect(pending.op.kind, StrategyOpKind.patch); + expect(pending.op.entityPublicId, 'page-1'); + expect(pending.op.payload, {'name': 'Retake'}); + expect(pending.op.expectedRevision, 6); + expect(intent.key, const EntitySyncKey.pageDescriptor('page-1')); + expect(queue.flushNowCount, 1); }); - test('late active-page lineups rehydrate after header sequence advance', - () async { - const strategyId = 'cloud-strategy'; - final pageOne = - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); - final beforeSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [pageOne], - lineupsByPage: { - 'page-1': [ - _remoteLineup( - strategyId: strategyId, - pageId: 'page-1', - lineupId: 'lineup-1', - notes: 'before', - ), - ], - }, - ); - final headerFirstSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - lineupsByPage: { - 'page-1': [ - _remoteLineup( - strategyId: strategyId, - pageId: 'page-1', - lineupId: 'lineup-1', - notes: 'before', - ), - ], - }, - ); - final lineupsArrivedSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - lineupsByPage: { - 'page-1': [ - _remoteLineup( - strategyId: strategyId, - pageId: 'page-1', - lineupId: 'lineup-1', - notes: 'after', - ), - ], - }, - ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(beforeSnapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + test('cloud page delete is persisted with the shell revision', () async { + final first = _page('page-1', 0); + final second = _page('page-2', 1); + final queue = _FakeStrategyOpQueueNotifier(); final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [first, second], + activePage: _pageSnapshot(first), + shellRevision: 12, + )), + queue: queue, ); - await container - .read(strategyPageSessionProvider.notifier) - .initializeForStrategy( - strategyId: strategyId, - source: StrategySource.cloud, - selectFirstPageIfNeeded: true, - ); - remoteNotifier.setSnapshot(headerFirstSnapshot); - await _settle(); - expect(container.read(lineUpProvider).lineUps.single.notes, 'before'); + await container.read(strategyProvider.notifier).deletePage('page-2'); - remoteNotifier.setSnapshot(lineupsArrivedSnapshot); - await _settle(); + final intent = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .single; + final pending = intent.value.pending; + expect(pending.op.entityType, StrategyOpEntityType.page); + expect(pending.op.kind, StrategyOpKind.delete); + expect(pending.op.entityPublicId, 'page-2'); + expect(pending.op.expectedRevision, 12); + expect(intent.key, const EntitySyncKey.pageDescriptor('page-2')); + expect(queue.flushNowCount, 1); + }); - expect(container.read(lineUpProvider).lineUps.single.notes, 'after'); - expect(queueNotifier.enqueueAllCount, 0); - expect(queueNotifier.flushNowCount, 0); + test('tombstone restore op carries the remote entity revision', () async { + final page = _page('page-1', 0); + final element = _textElement( + page.publicId, + 'restored-text', + 'deleted remotely', + revision: 4, + deleted: true, + ); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [element]), + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: element.publicId, position: const Offset(10, 20)) + ..text = 'restored locally', + ]); + container.read(strategyProvider.notifier).consumeScheduledCloudPageSync(); + + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final op = desired![EntitySyncKey.element(page.publicId, element.publicId)]; + expect(op, isNotNull); + expect(op!.kind, StrategyOpKind.add); + expect(op.expectedRevision, 4); + expect(op.toConvexJson()['expectedRevision'], 4); }); - test('active-page elements wait for header sequence before rehydrate', + test('active page update rehydrates without a strategy revision change', () async { - const strategyId = 'cloud-strategy'; - final pageOne = - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); - final beforeSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'before', - ), - ], - }, - ); - final elementsFirstSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'after', - ), - ], - }, - ); - final headerArrivedSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - elementsByPage: elementsFirstSnapshot.elementsByPage, + final page = _page('page-1', 0); + final before = _editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), + shellRevision: 4, ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(beforeSnapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final remote = _FakeRemoteEditorNotifier(before); final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), ); await container .read(strategyPageSessionProvider.notifier) .initializeForStrategy( - strategyId: strategyId, + strategyId: 'cloud-strategy', source: StrategySource.cloud, selectFirstPageIfNeeded: true, ); - - remoteNotifier.setSnapshot(elementsFirstSnapshot); - await _settle(); expect(container.read(textProvider).single.text, 'before'); - remoteNotifier.setSnapshot(headerArrivedSnapshot); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'after', contentRevision: 2), + shellRevision: 4, + )); await _settle(); - expect(container.read(textProvider).single.text, 'after'); - expect(queueNotifier.enqueueAllCount, 0); - expect(queueNotifier.flushNowCount, 0); }); - test('unchanged same-sequence section payload does not rehydrate', () async { - const strategyId = 'cloud-strategy'; - final pageOne = - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); - final beforeSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'before', - ), - ], - }, - ); - final updatedSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'after', - ), - ], + test('failed animated page switch restores the previous idle page', () async { + final pageOne = _page('page-1', 0); + final pageTwo = _page('page-2', 1); + final pageOneSnapshot = _pageSnapshot(pageOne, text: 'one'); + final pageTwoSnapshot = _pageSnapshot(pageTwo, text: 'two'); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot( + pages: [pageOne, pageTwo], + activePage: pageOneSnapshot, + ), + pageCatalog: { + pageOne.publicId: pageOneSnapshot, + pageTwo.publicId: pageTwoSnapshot, }, ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(beforeSnapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), ); await container .read(strategyPageSessionProvider.notifier) .initializeForStrategy( - strategyId: strategyId, + strategyId: 'cloud-strategy', source: StrategySource.cloud, selectFirstPageIfNeeded: true, ); + remote.failingPageId = pageTwo.publicId; + + await expectLater( + container + .read(strategyPageSessionProvider.notifier) + .setActivePageAnimated( + pageTwo.publicId, + direction: PageTransitionDirection.forward, + ), + throwsStateError, + ); - remoteNotifier.setSnapshot(updatedSnapshot); - await _settle(); - expect(container.read(textProvider).single.text, 'after'); - - container.read(textProvider.notifier).fromHive([ - PlacedText(id: 'local-text', position: const Offset(50, 60)) - ..text = 'local-only', - ]); - remoteNotifier.setSnapshot(updatedSnapshot); - await _settle(); - - expect(container.read(textProvider).single.text, 'local-only'); - expect(queueNotifier.flushNowCount, 0); + final session = container.read(strategyPageSessionProvider); + expect(session.activePageId, pageOne.publicId); + expect(session.transitionState, PageTransitionState.idle); + expect(container.read(transitionProvider).active, isFalse); + expect(remote.selectedPageIds, [pageTwo.publicId, pageOne.publicId]); }); - test('late same-sequence section rehydrate preserves local overlay', + test('remote hydration waits until an unchanged text draft is dismissed', () async { - const strategyId = 'cloud-strategy'; - final pageOne = - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); - final beforeSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'remote-a', - sortIndex: 0, - ), - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-2', - text: 'remote-b', - sortIndex: 1, - ), - ], - }, + final page = _page('page-1', 0); + final before = _editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), ); - final headerFirstSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - elementsByPage: beforeSnapshot.elementsByPage, - ); - final elementsArrivedSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'remote-a-server', - sortIndex: 0, - ), - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-2', - text: 'remote-b-updated', - sortIndex: 1, - ), - ], - }, - ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(beforeSnapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final remote = _FakeRemoteEditorNotifier(before); final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), ); await container .read(strategyPageSessionProvider.notifier) .initializeForStrategy( - strategyId: strategyId, + strategyId: 'cloud-strategy', source: StrategySource.cloud, selectFirstPageIfNeeded: true, ); - final localTextPayload = Map.from( - (PlacedText(id: 'text-1', position: const Offset(10, 20)) - ..text = 'local-a') - .toJson(), - )..putIfAbsent('elementType', () => 'text'); - container.read(activePageLiveSyncProvider.notifier).setStateForTest( - ActivePageLiveSyncState( - strategyPublicId: strategyId, - activePageId: 'page-1', - overlayByEntityKey: { - const EntitySyncKey.element('page-1', 'text-1'): ActivePageOverlayEntry( - entityKey: const EntitySyncKey.element('page-1', 'text-1'), - entityType: ActivePageOverlayEntityType.element, - desiredPayload: - cloudElementPayload(kind: 'text', data: localTextPayload), - desiredSortIndex: 0, - deletion: false, - baseRevision: 1, - dirtyAt: DateTime.now(), - ), - }, - ), - ); - - remoteNotifier.setSnapshot(headerFirstSnapshot); + const textId = 'text-page-1'; + container.read(textDraftProvider.notifier).setDraft(textId, 'before'); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'after', contentRevision: 2), + )); await _settle(); - remoteNotifier.setSnapshot(elementsArrivedSnapshot); + + expect(container.read(textProvider).single.text, 'before'); + expect(container.read(textDraftProvider)[textId], 'before'); + + container.read(textDraftProvider.notifier).clearDraft(textId); await _settle(); - final textsById = { - for (final text in container.read(textProvider)) text.id: text.text, - }; - expect(textsById['text-1'], 'local-a'); - expect(textsById['text-2'], 'remote-b-updated'); - expect(queueNotifier.flushNowCount, 0); + expect(container.read(textProvider).single.text, 'after'); }); - test('cloud agent addition queues an add op immediately', () async { - const strategyId = 'cloud-strategy'; - final snapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [ - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0), - ], + test('remote hydration preserves and queues a committed text draft', + () async { + final page = _page('page-1', 0); + final before = _editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(snapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final remote = _FakeRemoteEditorNotifier(before); final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), ); await container .read(strategyPageSessionProvider.notifier) .initializeForStrategy( - strategyId: strategyId, + strategyId: 'cloud-strategy', source: StrategySource.cloud, selectFirstPageIfNeeded: true, ); - container.read(agentProvider.notifier).addAgent( - PlacedAgent( - id: 'agent-1', - type: AgentType.jett, - position: const Offset(120, 160), - ), - ); + const textId = 'text-page-1'; + container.read(textDraftProvider.notifier).setDraft(textId, 'local-intent'); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: + _pageSnapshot(page, text: 'remote-change', contentRevision: 2), + )); + await _settle(); + + container.read(textDraftProvider.notifier).commitDraft(textId); await _settle(); + expect(container.read(textProvider).single.text, 'local-intent'); + expect(container.read(textDraftProvider), isEmpty); final pending = container.read(strategyOpQueueProvider).pending; + expect(pending, isNotEmpty); expect( - pending.any( - (entry) => - entry.op.kind == StrategyOpKind.add && - entry.op.entityType == StrategyOpEntityType.element && - entry.op.entityPublicId == 'agent-1' && - entry.op.pagePublicId == 'page-1', - ), + pending.any((item) => + item.op.entityPublicId == textId && + item.op.payload.toString().contains('local-intent')), isTrue, ); }); - test('cloud map change queues a strategy patch op', () async { - const strategyId = 'cloud-strategy'; - final snapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [ - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0), - ], - ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(snapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); - final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, - ); + test('rejected local intent stays visible and requires attention', () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'server-before'), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); await container .read(strategyPageSessionProvider.notifier) .initializeForStrategy( - strategyId: strategyId, + strategyId: 'cloud-strategy', source: StrategySource.cloud, selectFirstPageIfNeeded: true, ); - container.read(mapProvider.notifier).updateMap(MapValue.bind); + container.read(textProvider.notifier).commitText( + 'text-page-1', + 'local-losing-intent', + ); + await _settle(); + final op = container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op) + .firstWhere((op) => op.entityPublicId == 'text-page-1'); + + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + text: 'server-winner', + contentRevision: 2, + ), + )); + queue.reject(op); await _settle(); - final pending = container.read(strategyOpQueueProvider).pending; - final strategyPatch = pending - .map((entry) => entry.op) - .where( - (op) => - op.entityType == StrategyOpEntityType.strategy && - op.kind == StrategyOpKind.patch, - ) - .single; + expect(container.read(textProvider).single.text, 'local-losing-intent'); + expect(container.read(strategyOpQueueProvider).needsAttention, isTrue); expect( - strategyPatch.payload as Map, - containsPair('mapData', Maps.mapNames[MapValue.bind]), + container.read(strategyOpQueueProvider).attentionByEntityKey, + hasLength(1), ); + expect(container.read(strategyConflictProvider), hasLength(1)); + expect(container.read(strategyConflictProvider).single.opId, op.opId); }); - test('projected active-page merge prefers local overlay for touched entities', + test('inactive page shell update does not rehydrate the active canvas', () async { - const strategyId = 'cloud-strategy'; - final pageOne = - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); - final updatedSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'remote-a', - sortIndex: 0, - ), - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-2', - text: 'remote-b-updated', - sortIndex: 1, - ), - ], - }, + final pageOne = _page('page-1', 0); + final pageTwo = _page('page-2', 1); + final before = _editorSnapshot( + pages: [pageOne, pageTwo], + activePage: _pageSnapshot(pageOne, text: 'remote'), ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(updatedSnapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); - final container = ProviderContainer( - overrides: [ - strategyProvider.overrideWith( - () => _StaticStrategyProvider( - const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - ), - ), - remoteStrategySnapshotProvider.overrideWith(() => remoteNotifier), - strategyOpQueueProvider.overrideWith(() => queueNotifier), - ], - ); - addTearDown(container.dispose); - await container.read(remoteStrategySnapshotProvider.future); - - final localTextPayload = Map.from( - (PlacedText(id: 'text-1', position: const Offset(10, 20)) - ..text = 'local-a') - .toJson(), - )..putIfAbsent('elementType', () => 'text'); - container.read(activePageLiveSyncProvider.notifier).setStateForTest( - ActivePageLiveSyncState( - strategyPublicId: strategyId, - activePageId: 'page-1', - overlayByEntityKey: { - const EntitySyncKey.element('page-1', 'text-1'): ActivePageOverlayEntry( - entityKey: const EntitySyncKey.element('page-1', 'text-1'), - entityType: ActivePageOverlayEntityType.element, - desiredPayload: - cloudElementPayload(kind: 'text', data: localTextPayload), - desiredSortIndex: 0, - deletion: false, - baseRevision: 1, - dirtyAt: DateTime.now(), - ), - }, - ), - ); - - final projectedState = container - .read(activePageLiveSyncProvider.notifier) - .projectPageState(strategyPublicId: strategyId, pageId: 'page-1'); - - final textsById = { - for (final element in projectedState!.elements) - element.publicId: PlacedText.fromJson( - cloudPayloadData(element.payload), - ).text, - }; - expect(textsById['text-1'], 'local-a'); - expect(textsById['text-2'], 'remote-b-updated'); - }); - - test('reject refresh preserves local state and queues follow-up sync', - () async { - const strategyId = 'cloud-strategy'; - final pageOne = - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); - final initialSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'before') - ], - }, - ); - final updatedSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'after') - ], - }, - ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(initialSnapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final remote = _FakeRemoteEditorNotifier(before); final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), ); await container .read(strategyPageSessionProvider.notifier) .initializeForStrategy( - strategyId: strategyId, + strategyId: 'cloud-strategy', source: StrategySource.cloud, selectFirstPageIfNeeded: true, ); - container.read(textProvider.notifier).fromHive([ - PlacedText(id: 'local-text', position: const Offset(50, 60)) - ..text = 'local-only', - ]); - remoteNotifier.setSnapshot(updatedSnapshot); - queueNotifier.emitAcks(const [ - OpAck( - opId: 'op-1', - status: 'reject', - latestSequence: 2, - reason: 'conflict', - ), - ], [ - AckedEntityIntent( - entityKey: const EntitySyncKey.element('page-1', 'text-1'), - op: StrategyOp( - opId: 'op-1', - kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.element, - entityPublicId: 'text-1', - pagePublicId: 'page-1', - payload: cloudElementPayload( - kind: 'text', - data: {'text': 'after', 'elementType': 'text'}, - ), - ), - ack: const OpAck( - opId: 'op-1', - status: 'reject', - latestSequence: 2, - reason: 'conflict', - ), - ), + PlacedText(id: 'local', position: const Offset(5, 5))..text = 'local', ]); - await _settle(); - expect(remoteNotifier.refreshCount, 1); - expect(queueNotifier.syncDesiredOpsForPageCount, greaterThanOrEqualTo(1)); - expect(queueNotifier.flushNowCount, 0); - expect(container.read(textProvider).single.text, 'local-only'); - expect(container.read(strategyOpQueueProvider).pending, isNotEmpty); + remote.setSnapshot(_editorSnapshot( + pages: [pageOne, _page('page-2', 1, revision: 2, name: 'Renamed')], + activePage: _pageSnapshot(pageOne, text: 'remote'), + shellRevision: 2, + )); + await _settle(); + expect(container.read(textProvider).single.text, 'local'); }); - test('user page switch still flushes current cloud page', () async { - const strategyId = 'cloud-strategy'; - final snapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [ - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0), - _remotePage(strategyId: strategyId, pageId: 'page-2', sortIndex: 1), - ], - elementsByPage: { - 'page-2': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-2', - elementId: 'text-2', - text: 'page-two'), - ], - }, - ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(snapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + test('outbound diff waits for the matching active-page remote base', + () async { + final pageOne = _page('page-1', 0); + final pageTwo = _page('page-2', 1); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [pageOne, pageTwo], + activePage: _pageSnapshot(pageTwo, text: 'remote-two'), + )); final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), ); - - await container - .read(strategyPageSessionProvider.notifier) - .initializeForStrategy( - strategyId: strategyId, - source: StrategySource.cloud, - selectFirstPageIfNeeded: true, - ); - container.read(textProvider.notifier).fromHive([ - PlacedText(id: 'local-text', position: const Offset(50, 60)) - ..text = 'needs-sync', + PlacedText(id: 'local-one', position: const Offset(5, 5)) + ..text = 'local-one', ]); - await container - .read(strategyPageSessionProvider.notifier) - .setActivePage('page-2'); + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + ); - expect(queueNotifier.syncDesiredOpsForPageCount, 1); - expect(queueNotifier.flushNowCount, 1); - expect(container.read(textProvider).single.text, 'page-two'); + expect(desired, isNull); }); - test('cloud animated page switch uses shared transition state', () async { - const strategyId = 'cloud-strategy'; - final snapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [ - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0), - _remotePage(strategyId: strategyId, pageId: 'page-2', sortIndex: 1), - ], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'before', - ), - ], - 'page-2': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-2', - elementId: 'text-2', - text: 'after', - ), - ], - }, - ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(snapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + test('remote lineup survives hydration and an unrelated outbound diff', + () async { + final page = _page('page-1', 0); + final lineup = _lineup(page.publicId, 'lineup-1'); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'remote', lineups: [lineup]), + )); final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), ); - await container .read(strategyPageSessionProvider.notifier) .initializeForStrategy( - strategyId: strategyId, + strategyId: 'cloud-strategy', source: StrategySource.cloud, selectFirstPageIfNeeded: true, ); - container.read(textProvider.notifier).fromHive([ - PlacedText(id: 'local-text', position: const Offset(50, 60)) - ..text = 'needs-sync', - ]); + expect(container.read(lineUpProvider).groups.single.id, 'lineup-1'); + container.read(textProvider).single.position = const Offset(50, 60); - await container - .read(strategyPageSessionProvider.notifier) - .setActivePageAnimated( - 'page-2', - direction: PageTransitionDirection.forward, - ); + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + expect(desired, isNotNull); + expect(desired![EntitySyncKey.lineup(page.publicId, 'lineup-1')], isNull); expect( - container.read(strategyPageSessionProvider).transitionState, - PageTransitionState.animatingForward, - ); - final transitionState = - container.read(overlay_transition.transitionProvider); - expect(transitionState.hideView, isTrue); - expect( - transitionState.phase, - overlay_transition.PageTransitionPhase.preparing, + desired[EntitySyncKey.element(page.publicId, 'text-page-1')]?.kind, + StrategyOpKind.patch, ); - expect(transitionState.direction, PageTransitionDirection.forward); - expect(queueNotifier.flushNowCount, 1); - expect(container.read(textProvider).single.text, 'after'); }); - test('cloud relative page switch preserves backward transition direction', + test('page switch persists old intent and never waits indefinitely', () async { - const strategyId = 'cloud-strategy'; - final snapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [ - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0), - _remotePage(strategyId: strategyId, pageId: 'page-2', sortIndex: 1), - ], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'before', - ), - ], - 'page-2': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-2', - elementId: 'text-2', - text: 'after', - ), - ], - }, - ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(snapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); - final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, + final pageOne = _page('page-1', 0); + final pageTwo = _page('page-2', 1); + final first = _pageSnapshot(pageOne, text: 'one'); + final second = _pageSnapshot(pageTwo, text: 'two'); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot(pages: [pageOne, pageTwo], activePage: first), + pageCatalog: {'page-1': first, 'page-2': second}, + ); + final queue = _FakeStrategyOpQueueNotifier(blockFlush: true); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, ); - await container - .read(strategyPageSessionProvider.notifier) - .initializeForStrategy( - strategyId: strategyId, - source: StrategySource.cloud, - selectFirstPageIfNeeded: true, - ); - await container.read(strategyPageSessionProvider.notifier).setActivePage( - 'page-2', - ); - - queueNotifier - ..enqueueAllCount = 0 - ..syncDesiredOpsForPageCount = 0 - ..flushNowCount = 0 - ..enqueuedOps.clear(); container.read(textProvider.notifier).fromHive([ - PlacedText(id: 'page-two-draft', position: const Offset(40, 70)) - ..text = 'draft', + PlacedText(id: 'local-edit', position: const Offset(5, 5)) + ..text = 'unsent', ]); - await container - .read(strategyPageSessionProvider.notifier) - .switchRelativePage(PageSwitchDirection.previous); - - expect( - container.read(strategyPageSessionProvider).transitionState, - PageTransitionState.animatingBackward, - ); - final transitionState = - container.read(overlay_transition.transitionProvider); - expect(transitionState.hideView, isTrue); + await session.setActivePage('page-2').timeout(const Duration(seconds: 2)); + expect(session.activePageId, 'page-2'); + expect(remote.selectedPageIds, contains('page-2')); + expect(container.read(textProvider).single.text, 'two'); + expect(queue.flushNowCount, 1); expect( - transitionState.phase, - overlay_transition.PageTransitionPhase.preparing, + container.read(strategyOpQueueProvider).pending.any((pending) => + pending.op.pagePublicId == 'page-1' || + pending.op.entityPublicId == 'page-1'), + isTrue, ); - expect(transitionState.direction, PageTransitionDirection.backward); - expect(queueNotifier.syncDesiredOpsForPageCount, 1); - expect(queueNotifier.flushNowCount, 1); - expect(container.read(strategyPageSessionProvider).activePageId, 'page-1'); - expect(container.read(textProvider).single.text, 'before'); }); - test('pending cloud sync does not block projected active-page rehydrate', - () async { - const strategyId = 'cloud-strategy'; - final pageOne = - _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); - final initialSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 1, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'before') - ], - }, + test('persisted overlay wins over a late remote active-page base', () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'remote-before'), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, ); - final updatedSnapshot = _cloudSnapshot( - strategyId: strategyId, - sequence: 2, - pages: [pageOne], - elementsByPage: { - 'page-1': [ - _remoteText( - strategyId: strategyId, - pageId: 'page-1', - elementId: 'text-1', - text: 'after') - ], - }, - ); - - final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(initialSnapshot); - final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); - final container = await _cloudContainer( - strategyState: const StrategyState( - strategyId: strategyId, - strategyName: 'Cloud Strategy', - source: StrategySource.cloud, - storageDirectory: null, - isOpen: true, - ), - remoteNotifier: remoteNotifier, - queueNotifier: queueNotifier, - ); - - await container - .read(strategyPageSessionProvider.notifier) - .initializeForStrategy( - strategyId: strategyId, - source: StrategySource.cloud, - selectFirstPageIfNeeded: true, - ); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'local', position: const Offset(5, 5)) + ..text = 'local-intent', + ]); + await session.flushCurrentPage(); - container.read(strategySaveStateProvider.notifier) - ..markDirty() - ..setPendingCloudSync(true); - remoteNotifier.setSnapshot(updatedSnapshot); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'remote-after'), + )); await _settle(); - - expect(container.read(textProvider).single.text, 'after'); - expect(queueNotifier.enqueueAllCount, 0); - expect(queueNotifier.flushNowCount, 0); + expect(container.read(textProvider).single.text, 'local-intent'); + expect(container.read(strategyOpQueueProvider).pending, isNotEmpty); }); - test('user page switch still flushes current local page', () async { - final box = await _openStrategyBox('icarus-page-session-local-switch-'); - final strategy = _localStrategy( - strategyId: 'local-strategy', - firstText: 'before', - secondText: 'page-two', + test('local mode page switching keeps its shipped Hive shape', () async { + final box = await _openStrategyBox(); + final now = DateTime.utc(2026); + StrategyPage localPage(String id, int index, String value) => StrategyPage( + id: id, + name: 'Page ${index + 1}', + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: [ + PlacedText(id: 'text-$id', position: const Offset(1, 2)) + ..text = value, + ], + imageData: const [], + utilityData: const [], + sortIndex: index, + isAttack: true, + settings: StrategySettings(), + ); + await box.put( + 'local-strategy', + StrategyData( + id: 'local-strategy', + name: 'Local', + mapData: MapValue.ascent, + versionNumber: 1, + lastEdited: now, + folderID: null, + pages: [ + localPage('page-1', 0, 'one'), + localPage('page-2', 1, 'two'), + ], + ), ); - await box.put(strategy.id, strategy); - - final container = ProviderContainer(); + final container = ProviderContainer(overrides: [ + strategyOpQueueProvider.overrideWith( + () => _FakeStrategyOpQueueNotifier(), + ), + ]); addTearDown(container.dispose); - container.read(strategyProvider.notifier).setFromState( - const StrategyState( - strategyId: 'local-strategy', - strategyName: 'Local Strategy', - source: StrategySource.local, - storageDirectory: null, - isOpen: true, - ), - ); - container.listen(strategyPageSessionProvider, (_, __) {}); - - await container - .read(strategyPageSessionProvider.notifier) - .initializeForStrategy( - strategyId: strategy.id, + container.read(strategyProvider.notifier).setFromState(const StrategyState( + strategyId: 'local-strategy', + strategyName: 'Local', source: StrategySource.local, - selectFirstPageIfNeeded: true, - ); - - container.read(textProvider.notifier).fromHive([ - PlacedText(id: 'text-1', position: const Offset(10, 20))..text = 'draft', - ]); - - await container - .read(strategyPageSessionProvider.notifier) - .setActivePage('page-2'); - - final saved = box.get(strategy.id)!; - expect(saved.pages.first.textData.single.text, 'draft'); - expect(container.read(textProvider).single.text, 'page-two'); - }); - - test('initializeForStrategy does not flush before initial apply', () async { - final box = await _openStrategyBox('icarus-page-session-local-init-'); - final strategy = _localStrategy( + storageDirectory: null, + isOpen: true, + )); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( strategyId: 'local-strategy', - firstText: 'persisted', - secondText: 'page-two', + source: StrategySource.local, + selectFirstPageIfNeeded: true, ); - await box.put(strategy.id, strategy); - - final container = ProviderContainer(); - addTearDown(container.dispose); - container.read(strategyProvider.notifier).setFromState( - const StrategyState( - strategyId: 'local-strategy', - strategyName: 'Local Strategy', - source: StrategySource.local, - storageDirectory: null, - isOpen: true, - ), - ); - container.listen(strategyPageSessionProvider, (_, __) {}); - container.read(textProvider.notifier).fromHive([ - PlacedText(id: 'stray', position: const Offset(90, 90))..text = 'stray', - ]); - - await container - .read(strategyPageSessionProvider.notifier) - .initializeForStrategy( - strategyId: strategy.id, - source: StrategySource.local, - selectFirstPageIfNeeded: true, - ); - - final saved = box.get(strategy.id)!; - expect(saved.pages.first.textData.single.text, 'persisted'); - expect(container.read(textProvider).single.text, 'persisted'); + expect(container.read(textProvider).single.text, 'one'); + await session.setActivePage('page-2'); + expect(container.read(textProvider).single.text, 'two'); + expect(box.get('local-strategy')!.pages, hasLength(2)); }); } diff --git a/test/strategy_view_skeleton_test.dart b/test/strategy_view_skeleton_test.dart new file mode 100644 index 00000000..c2b800f7 --- /dev/null +++ b/test/strategy_view_skeleton_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/strategy_view_skeleton.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + testWidgets('loading skeleton fits the minimum desktop window', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 630)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ShadApp( + themeMode: ThemeMode.dark, + darkTheme: ShadThemeData( + brightness: Brightness.dark, + colorScheme: Settings.tacticalVioletTheme, + ), + home: const MediaQuery( + data: MediaQueryData( + size: Size(800, 630), + disableAnimations: true, + ), + child: StrategyViewSkeleton( + strategyName: 'SYNC BOUNDARY PROBE', + ), + ), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); +} diff --git a/test/text_editing_shortcut_scope_test.dart b/test/text_editing_shortcut_scope_test.dart index 834327d1..d3d29c8c 100644 --- a/test/text_editing_shortcut_scope_test.dart +++ b/test/text_editing_shortcut_scope_test.dart @@ -1,9 +1,37 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; void main() { + late String clipboardText; + + setUp(() { + clipboardText = ''; + TestWidgetsFlutterBinding.ensureInitialized() + .defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + switch (call.method) { + case 'Clipboard.getData': + return {'text': clipboardText}; + case 'Clipboard.setData': + final arguments = call.arguments as Map; + clipboardText = arguments['text'] as String? ?? ''; + case 'Clipboard.hasStrings': + return {'value': clipboardText.isNotEmpty}; + } + return null; + }); + }); + + tearDown(() { + TestWidgetsFlutterBinding.ensureInitialized() + .defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + testWidgets('renders without an opened app preferences Hive box', (tester) async { await tester.pumpWidget( @@ -21,4 +49,116 @@ void main() { expect(find.byType(TextField), findsOneWidget); expect(tester.takeException(), isNull); }); + + testWidgets('Ctrl+V pastes text instead of invoking the app shortcut', + (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + var pasteImageInvocations = 0; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.keyV, control: true): + PasteImageIntent(), + }, + child: Actions( + actions: >{ + PasteImageIntent: CallbackAction( + onInvoke: (_) { + pasteImageInvocations++; + return null; + }, + ), + }, + child: Scaffold( + body: TextEditingShortcutScope( + child: TextField(controller: controller), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + clipboardText = 'https://youtu.be/example'; + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(controller.text, 'https://youtu.be/example'); + expect(pasteImageInvocations, 0); + }); + + testWidgets('Ctrl+Z uses the text field undo history', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: TextEditingShortcutScope( + child: TextField(controller: controller, autofocus: true), + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.enterText(find.byType(TextField), 'draft text'); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(controller.text, isEmpty); + }); + + testWidgets('field-specific shortcuts take priority over text defaults', + (tester) async { + var submitInvocations = 0; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: TextEditingShortcutScope( + extraShortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): EnterTextIntent(), + }, + child: Actions( + actions: >{ + EnterTextIntent: CallbackAction( + onInvoke: (_) { + submitInvocations++; + return null; + }, + ), + }, + child: const TextField(autofocus: true), + ), + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(submitInvocations, 1); + }); } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..66f217c8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "edge-runtime", + include: ["convex/**/*.test.ts"], + }, +}); diff --git a/web/CONVEX_BROWSER_CLIENT.md b/web/CONVEX_BROWSER_CLIENT.md new file mode 100644 index 00000000..4b2de306 --- /dev/null +++ b/web/CONVEX_BROWSER_CLIENT.md @@ -0,0 +1,11 @@ +# Convex browser client + +`convex.browser.bundle.js` is the unmodified browser bundle produced by the +Apache-2.0-licensed `convex` npm package. It is checked in so the web client does +not depend on a third-party CDN at runtime. + +Regenerate it from the repository root after updating the pinned npm dependency: + +```sh +cp node_modules/convex/dist/browser.bundle.js web/convex.browser.bundle.js +``` diff --git a/web/convex.browser.bundle.js b/web/convex.browser.bundle.js new file mode 100644 index 00000000..82026ce2 --- /dev/null +++ b/web/convex.browser.bundle.js @@ -0,0 +1,4647 @@ +"use strict"; +var convex = (() => { + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + + // browser-bundle.js + var browser_bundle_exports = {}; + __export(browser_bundle_exports, { + BaseConvexClient: () => BaseConvexClient, + ConvexClient: () => ConvexClient, + ConvexHttpClient: () => ConvexHttpClient, + anyApi: () => anyApi, + convexQueryOptions: () => convexQueryOptions + }); + + // src/index.ts + var version = "1.45.0"; + + // src/values/base64.ts + var base64_exports = {}; + __export(base64_exports, { + byteLength: () => byteLength, + fromByteArray: () => fromByteArray, + fromByteArrayUrlSafeNoPadding: () => fromByteArrayUrlSafeNoPadding, + toByteArray: () => toByteArray + }); + var lookup = []; + var revLookup = []; + var Arr = Uint8Array; + var code = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + for (i = 0, len = code.length; i < len; ++i) { + lookup[i] = code[i]; + revLookup[code.charCodeAt(i)] = i; + } + var i; + var len; + revLookup["-".charCodeAt(0)] = 62; + revLookup["_".charCodeAt(0)] = 63; + function getLens(b64) { + var len = b64.length; + if (len % 4 > 0) { + throw new Error("Invalid string. Length must be a multiple of 4"); + } + var validLen = b64.indexOf("="); + if (validLen === -1) validLen = len; + var placeHoldersLen = validLen === len ? 0 : 4 - validLen % 4; + return [validLen, placeHoldersLen]; + } + function byteLength(b64) { + var lens = getLens(b64); + var validLen = lens[0]; + var placeHoldersLen = lens[1]; + return (validLen + placeHoldersLen) * 3 / 4 - placeHoldersLen; + } + function _byteLength(_b64, validLen, placeHoldersLen) { + return (validLen + placeHoldersLen) * 3 / 4 - placeHoldersLen; + } + function toByteArray(b64) { + var tmp; + var lens = getLens(b64); + var validLen = lens[0]; + var placeHoldersLen = lens[1]; + var arr = new Arr(_byteLength(b64, validLen, placeHoldersLen)); + var curByte = 0; + var len = placeHoldersLen > 0 ? validLen - 4 : validLen; + var i; + for (i = 0; i < len; i += 4) { + tmp = revLookup[b64.charCodeAt(i)] << 18 | revLookup[b64.charCodeAt(i + 1)] << 12 | revLookup[b64.charCodeAt(i + 2)] << 6 | revLookup[b64.charCodeAt(i + 3)]; + arr[curByte++] = tmp >> 16 & 255; + arr[curByte++] = tmp >> 8 & 255; + arr[curByte++] = tmp & 255; + } + if (placeHoldersLen === 2) { + tmp = revLookup[b64.charCodeAt(i)] << 2 | revLookup[b64.charCodeAt(i + 1)] >> 4; + arr[curByte++] = tmp & 255; + } + if (placeHoldersLen === 1) { + tmp = revLookup[b64.charCodeAt(i)] << 10 | revLookup[b64.charCodeAt(i + 1)] << 4 | revLookup[b64.charCodeAt(i + 2)] >> 2; + arr[curByte++] = tmp >> 8 & 255; + arr[curByte++] = tmp & 255; + } + return arr; + } + function tripletToBase64(num) { + return lookup[num >> 18 & 63] + lookup[num >> 12 & 63] + lookup[num >> 6 & 63] + lookup[num & 63]; + } + function encodeChunk(uint8, start, end) { + var tmp; + var output = []; + for (var i = start; i < end; i += 3) { + tmp = (uint8[i] << 16 & 16711680) + (uint8[i + 1] << 8 & 65280) + (uint8[i + 2] & 255); + output.push(tripletToBase64(tmp)); + } + return output.join(""); + } + function fromByteArray(uint8) { + var tmp; + var len = uint8.length; + var extraBytes = len % 3; + var parts = []; + var maxChunkLength = 16383; + for (var i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) { + parts.push( + encodeChunk( + uint8, + i, + i + maxChunkLength > len2 ? len2 : i + maxChunkLength + ) + ); + } + if (extraBytes === 1) { + tmp = uint8[len - 1]; + parts.push(lookup[tmp >> 2] + lookup[tmp << 4 & 63] + "=="); + } else if (extraBytes === 2) { + tmp = (uint8[len - 2] << 8) + uint8[len - 1]; + parts.push( + lookup[tmp >> 10] + lookup[tmp >> 4 & 63] + lookup[tmp << 2 & 63] + "=" + ); + } + return parts.join(""); + } + function fromByteArrayUrlSafeNoPadding(uint8) { + return fromByteArray(uint8).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + } + + // src/common/index.ts + function parseArgs(args) { + if (args === void 0) { + return {}; + } + if (!isSimpleObject(args)) { + throw new Error( + `The arguments to a Convex function must be an object. Received: ${args}` + ); + } + return args; + } + function validateDeploymentUrl(deploymentUrl) { + if (typeof deploymentUrl === "undefined") { + throw new Error( + `Client created with undefined deployment address. If you used an environment variable, check that it's set.` + ); + } + if (typeof deploymentUrl !== "string") { + throw new Error( + `Invalid deployment address: found ${deploymentUrl}".` + ); + } + if (!(deploymentUrl.startsWith("http:") || deploymentUrl.startsWith("https:"))) { + throw new Error( + `Invalid deployment address: Must start with "https://" or "http://". Found "${deploymentUrl}".` + ); + } + try { + new URL(deploymentUrl); + } catch { + throw new Error( + `Invalid deployment address: "${deploymentUrl}" is not a valid URL. If you believe this URL is correct, use the \`skipConvexDeploymentUrlCheck\` option to bypass this.` + ); + } + if (deploymentUrl.endsWith(".convex.site")) { + throw new Error( + `Invalid deployment address: "${deploymentUrl}" ends with .convex.site, which is used for HTTP Actions. Convex deployment URLs typically end with .convex.cloud? If you believe this URL is correct, use the \`skipConvexDeploymentUrlCheck\` option to bypass this.` + ); + } + } + function isSimpleObject(value) { + const isObject = typeof value === "object"; + const prototype = Object.getPrototypeOf(value); + const isSimple = prototype === null || prototype === Object.prototype || // Objects generated from other contexts (e.g. across Node.js `vm` modules) will not satisfy the previous + // conditions but are still simple objects. + prototype?.constructor?.name === "Object"; + return isObject && isSimple; + } + + // src/values/value.ts + var LITTLE_ENDIAN = true; + var MIN_INT64 = BigInt("-9223372036854775808"); + var MAX_INT64 = BigInt("9223372036854775807"); + var ZERO = BigInt("0"); + var EIGHT = BigInt("8"); + var TWOFIFTYSIX = BigInt("256"); + var COMMIT_TS_UNRESOLVED = "This commit timestamp is unresolved: its value is assigned when the mutation commits. Read the document after the mutation completes to get its value."; + var CommitTsPlaceholder = class { + [Symbol.toPrimitive](hint) { + if (hint === "string") { + return this.toString(); + } + throw new Error(COMMIT_TS_UNRESOLVED); + } + valueOf() { + throw new Error(COMMIT_TS_UNRESOLVED); + } + toJSON() { + throw new Error(COMMIT_TS_UNRESOLVED); + } + toString() { + return "[unresolved commit timestamp]"; + } + }; + var commitTsPlaceholder = new CommitTsPlaceholder(); + function isSpecial(n) { + return Number.isNaN(n) || !Number.isFinite(n) || Object.is(n, -0); + } + function slowBigIntToBase64(value) { + if (value < ZERO) { + value -= MIN_INT64 + MIN_INT64; + } + let hex = value.toString(16); + if (hex.length % 2 === 1) hex = "0" + hex; + const bytes = new Uint8Array(new ArrayBuffer(8)); + let i = 0; + for (const hexByte of hex.match(/.{2}/g).reverse()) { + bytes.set([parseInt(hexByte, 16)], i++); + value >>= EIGHT; + } + return fromByteArray(bytes); + } + function slowBase64ToBigInt(encoded) { + const integerBytes = toByteArray(encoded); + if (integerBytes.byteLength !== 8) { + throw new Error( + `Received ${integerBytes.byteLength} bytes, expected 8 for $integer` + ); + } + let value = ZERO; + let power = ZERO; + for (const byte of integerBytes) { + value += BigInt(byte) * TWOFIFTYSIX ** power; + power++; + } + if (value > MAX_INT64) { + value += MIN_INT64 + MIN_INT64; + } + return value; + } + function modernBigIntToBase64(value) { + if (value < MIN_INT64 || MAX_INT64 < value) { + throw new Error( + `BigInt ${value} does not fit into a 64-bit signed integer.` + ); + } + const buffer = new ArrayBuffer(8); + new DataView(buffer).setBigInt64(0, value, true); + return fromByteArray(new Uint8Array(buffer)); + } + function modernBase64ToBigInt(encoded) { + const integerBytes = toByteArray(encoded); + if (integerBytes.byteLength !== 8) { + throw new Error( + `Received ${integerBytes.byteLength} bytes, expected 8 for $integer` + ); + } + const intBytesView = new DataView(integerBytes.buffer); + return intBytesView.getBigInt64(0, true); + } + var bigIntToBase64 = DataView.prototype.setBigInt64 ? modernBigIntToBase64 : slowBigIntToBase64; + var base64ToBigInt = DataView.prototype.getBigInt64 ? modernBase64ToBigInt : slowBase64ToBigInt; + var MAX_IDENTIFIER_LEN = 1024; + function validateObjectField(k) { + if (k.length > MAX_IDENTIFIER_LEN) { + throw new Error( + `Field name ${k} exceeds maximum field name length ${MAX_IDENTIFIER_LEN}.` + ); + } + if (k.startsWith("$")) { + throw new Error(`Field name ${k} starts with a '$', which is reserved.`); + } + for (let i = 0; i < k.length; i += 1) { + const charCode = k.charCodeAt(i); + if (charCode < 32 || charCode >= 127) { + throw new Error( + `Field name ${k} has invalid character '${k[i]}': Field names can only contain non-control ASCII characters` + ); + } + } + } + function jsonToConvex(value) { + if (value === null) { + return value; + } + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + return value; + } + if (typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value.map((value2) => jsonToConvex(value2)); + } + if (typeof value !== "object") { + throw new Error(`Unexpected type of ${value}`); + } + const entries = Object.entries(value); + if (entries.length === 1) { + const key = entries[0][0]; + if (key === "$bytes") { + if (typeof value.$bytes !== "string") { + throw new Error(`Malformed $bytes field on ${value}`); + } + return toByteArray(value.$bytes).buffer; + } + if (key === "$integer") { + if (typeof value.$integer !== "string") { + throw new Error(`Malformed $integer field on ${value}`); + } + return base64ToBigInt(value.$integer); + } + if (key === "$float") { + if (typeof value.$float !== "string") { + throw new Error(`Malformed $float field on ${value}`); + } + const floatBytes = toByteArray(value.$float); + if (floatBytes.byteLength !== 8) { + throw new Error( + `Received ${floatBytes.byteLength} bytes, expected 8 for $float` + ); + } + const floatBytesView = new DataView(floatBytes.buffer); + const float = floatBytesView.getFloat64(0, LITTLE_ENDIAN); + if (!isSpecial(float)) { + throw new Error(`Float ${float} should be encoded as a number`); + } + return float; + } + if (key === "$commitTs") { + if (value.$commitTs !== null) { + throw new Error(`Malformed $commitTs field on ${value}`); + } + return commitTsPlaceholder; + } + if (key === "$set") { + throw new Error( + `Received a Set which is no longer supported as a Convex type.` + ); + } + if (key === "$map") { + throw new Error( + `Received a Map which is no longer supported as a Convex type.` + ); + } + } + const out = {}; + for (const [k, v] of Object.entries(value)) { + validateObjectField(k); + out[k] = jsonToConvex(v); + } + return out; + } + var MAX_VALUE_FOR_ERROR_LEN = 16384; + function stringifyValueForError(value) { + const str = JSON.stringify(value, (_key, value2) => { + if (value2 === void 0) { + return "undefined"; + } + if (typeof value2 === "bigint") { + return `${value2.toString()}n`; + } + return value2; + }); + if (str.length > MAX_VALUE_FOR_ERROR_LEN) { + const rest = "[...truncated]"; + let truncateAt = MAX_VALUE_FOR_ERROR_LEN - rest.length; + const codePoint = str.codePointAt(truncateAt - 1); + if (codePoint !== void 0 && codePoint > 65535) { + truncateAt -= 1; + } + return str.substring(0, truncateAt) + rest; + } + return str; + } + function convexToJsonInternal(value, originalValue, context, includeTopLevelUndefined) { + if (value === void 0) { + const contextText = context && ` (present at path ${context} in original object ${stringifyValueForError( + originalValue + )})`; + throw new Error( + `undefined is not a valid Convex value${contextText}. To learn about Convex's supported types, see https://docs.convex.dev/using/types.` + ); + } + if (value === null) { + return value; + } + if (typeof value === "bigint") { + if (value < MIN_INT64 || MAX_INT64 < value) { + throw new Error( + `BigInt ${value} does not fit into a 64-bit signed integer.` + ); + } + return { $integer: bigIntToBase64(value) }; + } + if (typeof value === "number") { + if (isSpecial(value)) { + const buffer = new ArrayBuffer(8); + new DataView(buffer).setFloat64(0, value, LITTLE_ENDIAN); + return { $float: fromByteArray(new Uint8Array(buffer)) }; + } else { + return value; + } + } + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + return value; + } + if (value instanceof ArrayBuffer) { + return { $bytes: fromByteArray(new Uint8Array(value)) }; + } + if (value instanceof CommitTsPlaceholder) { + return { $commitTs: null }; + } + if (Array.isArray(value)) { + return value.map( + (value2, i) => convexToJsonInternal(value2, originalValue, context + `[${i}]`, false) + ); + } + if (value instanceof Set) { + throw new Error( + errorMessageForUnsupportedType(context, "Set", [...value], originalValue) + ); + } + if (value instanceof Map) { + throw new Error( + errorMessageForUnsupportedType(context, "Map", [...value], originalValue) + ); + } + if (!isSimpleObject(value)) { + const theType = value?.constructor?.name; + const typeName = theType ? `${theType} ` : ""; + throw new Error( + errorMessageForUnsupportedType(context, typeName, value, originalValue) + ); + } + const out = {}; + const entries = Object.entries(value); + entries.sort(([k1, _v1], [k2, _v2]) => k1 === k2 ? 0 : k1 < k2 ? -1 : 1); + for (const [k, v] of entries) { + if (v !== void 0) { + validateObjectField(k); + out[k] = convexToJsonInternal(v, originalValue, context + `.${k}`, false); + } else if (includeTopLevelUndefined) { + validateObjectField(k); + out[k] = convexOrUndefinedToJsonInternal( + v, + originalValue, + context + `.${k}` + ); + } + } + return out; + } + function errorMessageForUnsupportedType(context, typeName, value, originalValue) { + if (context) { + return `${typeName}${stringifyValueForError( + value + )} is not a supported Convex type (present at path ${context} in original object ${stringifyValueForError( + originalValue + )}). To learn about Convex's supported types, see https://docs.convex.dev/using/types.`; + } else { + return `${typeName}${stringifyValueForError( + value + )} is not a supported Convex type.`; + } + } + function convexOrUndefinedToJsonInternal(value, originalValue, context) { + if (value === void 0) { + return { $undefined: null }; + } else { + if (originalValue === void 0) { + throw new Error( + `Programming error. Current value is ${stringifyValueForError( + value + )} but original value is undefined` + ); + } + return convexToJsonInternal(value, originalValue, context, false); + } + } + function convexToJson(value) { + return convexToJsonInternal(value, value, "", false); + } + + // src/values/errors.ts + var IDENTIFYING_FIELD = Symbol.for("ConvexError"); + var ConvexError = class extends Error { + name = "ConvexError"; + data; + [IDENTIFYING_FIELD] = true; + constructor(data) { + super(typeof data === "string" ? data : stringifyValueForError(data)); + this.data = data; + } + }; + + // src/browser/logging.ts + var INFO_COLOR = "color:rgb(0, 145, 255)"; + function prefix_for_source(source) { + switch (source) { + case "query": + return "Q"; + case "mutation": + return "M"; + case "action": + return "A"; + case "any": + return "?"; + } + } + var DefaultLogger = class { + _onLogLineFuncs; + _verbose; + constructor(options) { + this._onLogLineFuncs = {}; + this._verbose = options.verbose; + } + addLogLineListener(func) { + let id = Math.random().toString(36).substring(2, 15); + for (let i = 0; i < 10; i++) { + if (this._onLogLineFuncs[id] === void 0) { + break; + } + id = Math.random().toString(36).substring(2, 15); + } + this._onLogLineFuncs[id] = func; + return () => { + delete this._onLogLineFuncs[id]; + }; + } + logVerbose(...args) { + if (this._verbose) { + for (const func of Object.values(this._onLogLineFuncs)) { + func("debug", `${(/* @__PURE__ */ new Date()).toISOString()}`, ...args); + } + } + } + log(...args) { + for (const func of Object.values(this._onLogLineFuncs)) { + func("info", ...args); + } + } + warn(...args) { + for (const func of Object.values(this._onLogLineFuncs)) { + func("warn", ...args); + } + } + error(...args) { + for (const func of Object.values(this._onLogLineFuncs)) { + func("error", ...args); + } + } + }; + function instantiateDefaultLogger(options) { + const logger = new DefaultLogger(options); + logger.addLogLineListener((level, ...args) => { + switch (level) { + case "debug": + console.debug(...args); + break; + case "info": + console.log(...args); + break; + case "warn": + console.warn(...args); + break; + case "error": + console.error(...args); + break; + default: { + level; + console.log(...args); + } + } + }); + return logger; + } + function instantiateNoopLogger(options) { + return new DefaultLogger(options); + } + function logForFunction(logger, type, source, udfPath, message) { + const prefix = prefix_for_source(source); + if (typeof message === "object") { + message = `ConvexError ${JSON.stringify(message.errorData, null, 2)}`; + } + if (type === "info") { + const match = message.match(/^\[.*?\] /); + if (match === null) { + logger.error( + `[CONVEX ${prefix}(${udfPath})] Could not parse console.log` + ); + return; + } + const level = message.slice(1, match[0].length - 2); + const args = message.slice(match[0].length); + logger.log(`%c[CONVEX ${prefix}(${udfPath})] [${level}]`, INFO_COLOR, args); + } else { + logger.error(`[CONVEX ${prefix}(${udfPath})] ${message}`); + } + } + function logFatalError(logger, message) { + const errorMessage = `[CONVEX FATAL ERROR] ${message}`; + logger.error(errorMessage); + return new Error(errorMessage); + } + function createHybridErrorStacktrace(source, udfPath, result) { + const prefix = prefix_for_source(source); + return `[CONVEX ${prefix}(${udfPath})] ${result.errorMessage} + Called by client`; + } + function forwardData(result, error) { + error.data = result.errorData; + return error; + } + + // src/browser/sync/udf_path_utils.ts + function canonicalizeUdfPath(udfPath) { + const pieces = udfPath.split(":"); + let moduleName; + let functionName2; + if (pieces.length === 1) { + moduleName = pieces[0]; + functionName2 = "default"; + } else { + moduleName = pieces.slice(0, pieces.length - 1).join(":"); + functionName2 = pieces[pieces.length - 1]; + } + if (moduleName.endsWith(".js")) { + moduleName = moduleName.slice(0, -3); + } + return `${moduleName}:${functionName2}`; + } + function serializePathAndArgs(udfPath, args) { + return JSON.stringify({ + udfPath: canonicalizeUdfPath(udfPath), + args: convexToJson(args) + }); + } + function serializePaginatedPathAndArgs(udfPath, args, options) { + const { initialNumItems, id } = options; + const result = JSON.stringify({ + type: "paginated", + udfPath: canonicalizeUdfPath(udfPath), + args: convexToJson(args), + options: convexToJson({ initialNumItems, id }) + }); + return result; + } + function serializedQueryTokenIsPaginated(token) { + return JSON.parse(token).type === "paginated"; + } + + // src/browser/sync/local_state.ts + var LocalSyncState = class { + nextQueryId; + querySetVersion; + querySet; + queryIdToToken; + identityVersion; + auth; + outstandingQueriesOlderThanRestart; + outstandingAuthOlderThanRestart; + paused; + pendingQuerySetModifications; + constructor() { + this.nextQueryId = 0; + this.querySetVersion = 0; + this.identityVersion = 0; + this.querySet = /* @__PURE__ */ new Map(); + this.queryIdToToken = /* @__PURE__ */ new Map(); + this.outstandingQueriesOlderThanRestart = /* @__PURE__ */ new Set(); + this.outstandingAuthOlderThanRestart = false; + this.paused = false; + this.pendingQuerySetModifications = /* @__PURE__ */ new Map(); + } + hasSyncedPastLastReconnect() { + return this.outstandingQueriesOlderThanRestart.size === 0 && !this.outstandingAuthOlderThanRestart; + } + markAuthCompletion() { + this.outstandingAuthOlderThanRestart = false; + } + subscribe(udfPath, args, journal, componentPath) { + const canonicalizedUdfPath = canonicalizeUdfPath(udfPath); + const queryToken = serializePathAndArgs(canonicalizedUdfPath, args); + const existingEntry = this.querySet.get(queryToken); + if (existingEntry !== void 0) { + existingEntry.numSubscribers += 1; + return { + queryToken, + modification: null, + unsubscribe: () => this.removeSubscriber(queryToken) + }; + } else { + const queryId = this.nextQueryId++; + const query = { + id: queryId, + canonicalizedUdfPath, + args, + numSubscribers: 1, + journal, + componentPath + }; + this.querySet.set(queryToken, query); + this.queryIdToToken.set(queryId, queryToken); + const baseVersion = this.querySetVersion; + const newVersion = this.querySetVersion + 1; + const add = { + type: "Add", + queryId, + udfPath: canonicalizedUdfPath, + args: [convexToJson(args)], + journal, + componentPath + }; + if (this.paused) { + this.pendingQuerySetModifications.set(queryId, add); + } else { + this.querySetVersion = newVersion; + } + const modification = { + type: "ModifyQuerySet", + baseVersion, + newVersion, + modifications: [add] + }; + return { + queryToken, + modification, + unsubscribe: () => this.removeSubscriber(queryToken) + }; + } + } + transition(transition) { + for (const modification of transition.modifications) { + switch (modification.type) { + case "QueryUpdated": + case "QueryFailed": { + this.outstandingQueriesOlderThanRestart.delete(modification.queryId); + const journal = modification.journal; + if (journal !== void 0) { + const queryToken = this.queryIdToToken.get(modification.queryId); + if (queryToken !== void 0) { + this.querySet.get(queryToken).journal = journal; + } + } + break; + } + case "QueryRemoved": { + this.outstandingQueriesOlderThanRestart.delete(modification.queryId); + break; + } + default: { + modification; + throw new Error(`Invalid modification ${modification.type}`); + } + } + } + } + queryId(udfPath, args) { + const canonicalizedUdfPath = canonicalizeUdfPath(udfPath); + const queryToken = serializePathAndArgs(canonicalizedUdfPath, args); + const existingEntry = this.querySet.get(queryToken); + if (existingEntry !== void 0) { + return existingEntry.id; + } + return null; + } + isCurrentOrNewerAuthVersion(version2) { + return version2 >= this.identityVersion; + } + getAuth() { + return this.auth; + } + setAuth(value) { + this.auth = { + tokenType: "User", + value + }; + const baseVersion = this.identityVersion; + if (!this.paused) { + this.identityVersion = baseVersion + 1; + } + return { + type: "Authenticate", + baseVersion, + ...this.auth + }; + } + setAdminAuth(value, actingAs) { + const auth = { + tokenType: "Admin", + value, + impersonating: actingAs + }; + this.auth = auth; + const baseVersion = this.identityVersion; + if (!this.paused) { + this.identityVersion = baseVersion + 1; + } + return { + type: "Authenticate", + baseVersion, + ...auth + }; + } + clearAuth() { + this.auth = void 0; + this.markAuthCompletion(); + const baseVersion = this.identityVersion; + if (!this.paused) { + this.identityVersion = baseVersion + 1; + } + return { + type: "Authenticate", + tokenType: "None", + baseVersion + }; + } + hasAuth() { + return !!this.auth; + } + isNewAuth(value) { + return this.auth?.value !== value; + } + queryPath(queryId) { + const pathAndArgs = this.queryIdToToken.get(queryId); + if (pathAndArgs) { + return this.querySet.get(pathAndArgs).canonicalizedUdfPath; + } + return null; + } + queryArgs(queryId) { + const pathAndArgs = this.queryIdToToken.get(queryId); + if (pathAndArgs) { + return this.querySet.get(pathAndArgs).args; + } + return null; + } + queryToken(queryId) { + return this.queryIdToToken.get(queryId) ?? null; + } + queryJournal(queryToken) { + return this.querySet.get(queryToken)?.journal; + } + restart() { + this.unpause(); + this.outstandingQueriesOlderThanRestart.clear(); + const modifications = []; + for (const localQuery of this.querySet.values()) { + const add = { + type: "Add", + queryId: localQuery.id, + udfPath: localQuery.canonicalizedUdfPath, + args: [convexToJson(localQuery.args)], + journal: localQuery.journal, + componentPath: localQuery.componentPath + }; + modifications.push(add); + this.outstandingQueriesOlderThanRestart.add(localQuery.id); + } + this.querySetVersion = 1; + const querySet = { + type: "ModifyQuerySet", + baseVersion: 0, + newVersion: 1, + modifications + }; + if (!this.auth) { + this.identityVersion = 0; + return [querySet, void 0]; + } + this.outstandingAuthOlderThanRestart = true; + const authenticate = { + type: "Authenticate", + baseVersion: 0, + ...this.auth + }; + this.identityVersion = 1; + return [querySet, authenticate]; + } + pause() { + this.paused = true; + } + resume() { + const querySet = this.pendingQuerySetModifications.size > 0 ? { + type: "ModifyQuerySet", + baseVersion: this.querySetVersion, + newVersion: ++this.querySetVersion, + modifications: Array.from( + this.pendingQuerySetModifications.values() + ) + } : void 0; + const authenticate = this.auth !== void 0 ? { + type: "Authenticate", + baseVersion: this.identityVersion++, + ...this.auth + } : void 0; + this.unpause(); + return [querySet, authenticate]; + } + unpause() { + this.paused = false; + this.pendingQuerySetModifications.clear(); + } + removeSubscriber(queryToken) { + const localQuery = this.querySet.get(queryToken); + if (localQuery.numSubscribers > 1) { + localQuery.numSubscribers -= 1; + return null; + } else { + this.querySet.delete(queryToken); + this.queryIdToToken.delete(localQuery.id); + this.outstandingQueriesOlderThanRestart.delete(localQuery.id); + const baseVersion = this.querySetVersion; + const newVersion = this.querySetVersion + 1; + const remove = { + type: "Remove", + queryId: localQuery.id + }; + if (this.paused) { + if (this.pendingQuerySetModifications.has(localQuery.id)) { + this.pendingQuerySetModifications.delete(localQuery.id); + } else { + this.pendingQuerySetModifications.set(localQuery.id, remove); + } + } else { + this.querySetVersion = newVersion; + } + return { + type: "ModifyQuerySet", + baseVersion, + newVersion, + modifications: [remove] + }; + } + } + }; + + // src/browser/sync/request_manager.ts + var RequestManager = class { + constructor(logger, markConnectionStateDirty) { + this.logger = logger; + this.markConnectionStateDirty = markConnectionStateDirty; + this.inflightRequests = /* @__PURE__ */ new Map(); + this.requestsOlderThanRestart = /* @__PURE__ */ new Set(); + } + inflightRequests; + requestsOlderThanRestart; + inflightMutationsCount = 0; + inflightActionsCount = 0; + request(message, sent) { + const result = new Promise((resolve) => { + const status = sent ? "Requested" : "NotSent"; + this.inflightRequests.set(message.requestId, { + message, + status: { status, requestedAt: /* @__PURE__ */ new Date(), onResult: resolve } + }); + if (message.type === "Mutation") { + this.inflightMutationsCount++; + } else if (message.type === "Action") { + this.inflightActionsCount++; + } + }); + this.markConnectionStateDirty(); + return result; + } + /** + * Update the state after receiving a response. + * + * @returns A RequestId if the request is complete and its optimistic update + * can be dropped, null otherwise. + */ + onResponse(response) { + const requestInfo = this.inflightRequests.get(response.requestId); + if (requestInfo === void 0) { + return null; + } + if (requestInfo.status.status === "Completed") { + return null; + } + const udfType = requestInfo.message.type === "Mutation" ? "mutation" : "action"; + const udfPath = requestInfo.message.udfPath; + for (const line of response.logLines) { + logForFunction(this.logger, "info", udfType, udfPath, line); + } + const status = requestInfo.status; + let result; + let onResolve; + if (response.success) { + result = { + success: true, + logLines: response.logLines, + value: jsonToConvex(response.result) + }; + onResolve = () => status.onResult(result); + } else { + const errorMessage = response.result; + const { errorData } = response; + logForFunction(this.logger, "error", udfType, udfPath, errorMessage); + result = { + success: false, + errorMessage, + errorData: errorData !== void 0 ? jsonToConvex(errorData) : void 0, + logLines: response.logLines + }; + onResolve = () => status.onResult(result); + } + if (response.type === "ActionResponse" || !response.success) { + onResolve(); + this.inflightRequests.delete(response.requestId); + this.requestsOlderThanRestart.delete(response.requestId); + if (requestInfo.message.type === "Action") { + this.inflightActionsCount--; + } else if (requestInfo.message.type === "Mutation") { + this.inflightMutationsCount--; + } + this.markConnectionStateDirty(); + return { requestId: response.requestId, result }; + } + requestInfo.status = { + status: "Completed", + result, + ts: response.ts, + onResolve + }; + return null; + } + // Remove and returns completed requests. + removeCompleted(ts) { + const completeRequests = /* @__PURE__ */ new Map(); + for (const [requestId, requestInfo] of this.inflightRequests.entries()) { + const status = requestInfo.status; + if (status.status === "Completed" && status.ts.lessThanOrEqual(ts)) { + status.onResolve(); + completeRequests.set(requestId, status.result); + if (requestInfo.message.type === "Mutation") { + this.inflightMutationsCount--; + } else if (requestInfo.message.type === "Action") { + this.inflightActionsCount--; + } + this.inflightRequests.delete(requestId); + this.requestsOlderThanRestart.delete(requestId); + } + } + if (completeRequests.size > 0) { + this.markConnectionStateDirty(); + } + return completeRequests; + } + restart() { + this.requestsOlderThanRestart = new Set(this.inflightRequests.keys()); + const allMessages = []; + for (const [requestId, value] of this.inflightRequests) { + if (value.status.status === "NotSent") { + value.status.status = "Requested"; + allMessages.push(value.message); + continue; + } + if (value.message.type === "Mutation") { + allMessages.push(value.message); + } else if (value.message.type === "Action") { + this.inflightRequests.delete(requestId); + this.requestsOlderThanRestart.delete(requestId); + this.inflightActionsCount--; + if (value.status.status === "Completed") { + throw new Error("Action should never be in 'Completed' state"); + } + value.status.onResult({ + success: false, + errorMessage: "Connection lost while action was in flight", + logLines: [] + }); + } + } + this.markConnectionStateDirty(); + return allMessages; + } + resume() { + const allMessages = []; + for (const [, value] of this.inflightRequests) { + if (value.status.status === "NotSent") { + value.status.status = "Requested"; + allMessages.push(value.message); + continue; + } + } + return allMessages; + } + /** + * @returns true if there are any requests that have been requested but have + * not be completed yet. + */ + hasIncompleteRequests() { + for (const requestInfo of this.inflightRequests.values()) { + if (requestInfo.status.status === "Requested") { + return true; + } + } + return false; + } + /** + * @returns true if there are any inflight requests, including ones that have + * completed on the server, but have not been applied. + */ + hasInflightRequests() { + return this.inflightRequests.size > 0; + } + /** + * @returns true if there are any inflight requests, that have been hanging around + * since prior to the most recent restart. + */ + hasSyncedPastLastReconnect() { + return this.requestsOlderThanRestart.size === 0; + } + timeOfOldestInflightRequest() { + if (this.inflightRequests.size === 0) { + return null; + } + let oldestInflightRequest = Date.now(); + for (const request of this.inflightRequests.values()) { + if (request.status.status !== "Completed") { + if (request.status.requestedAt.getTime() < oldestInflightRequest) { + oldestInflightRequest = request.status.requestedAt.getTime(); + } + } + } + return new Date(oldestInflightRequest); + } + /** + * @returns The number of mutations currently in flight. + */ + inflightMutations() { + return this.inflightMutationsCount; + } + /** + * @returns The number of actions currently in flight. + */ + inflightActions() { + return this.inflightActionsCount; + } + }; + + // src/server/functionName.ts + var functionName = Symbol.for("functionName"); + + // src/server/components/paths.ts + var toReferencePath = Symbol.for("toReferencePath"); + function extractReferencePath(reference) { + return reference[toReferencePath] ?? null; + } + function isFunctionHandle(s) { + return s.startsWith("function://"); + } + function getFunctionAddress(functionReference) { + let functionAddress; + if (typeof functionReference === "string") { + if (isFunctionHandle(functionReference)) { + functionAddress = { functionHandle: functionReference }; + } else { + functionAddress = { name: functionReference }; + } + } else if (functionReference[functionName]) { + functionAddress = { name: functionReference[functionName] }; + } else { + const referencePath = extractReferencePath(functionReference); + if (!referencePath) { + throw new Error(`${functionReference} is not a functionReference`); + } + functionAddress = { reference: referencePath }; + } + return functionAddress; + } + + // src/server/api.ts + function getFunctionName(functionReference) { + const address = getFunctionAddress(functionReference); + if (address.name === void 0) { + if (address.functionHandle !== void 0) { + throw new Error( + `Expected function reference like "api.file.func" or "internal.file.func", but received function handle ${address.functionHandle}` + ); + } else if (address.reference !== void 0) { + throw new Error( + `Expected function reference in the current component like "api.file.func" or "internal.file.func", but received reference ${address.reference}` + ); + } + throw new Error( + `Expected function reference like "api.file.func" or "internal.file.func", but received ${JSON.stringify(address)}` + ); + } + if (typeof functionReference === "string") return functionReference; + const name = functionReference[functionName]; + if (!name) { + throw new Error(`${functionReference} is not a functionReference`); + } + return name; + } + function createApi(pathParts = []) { + const handler = { + get(_, prop) { + if (typeof prop === "string") { + const newParts = [...pathParts, prop]; + return createApi(newParts); + } else if (prop === functionName) { + if (pathParts.length < 2) { + const found = ["api", ...pathParts].join("."); + throw new Error( + `API path is expected to be of the form \`api.moduleName.functionName\`. Found: \`${found}\`` + ); + } + const path = pathParts.slice(0, -1).join("/"); + const exportName = pathParts[pathParts.length - 1]; + if (exportName === "default") { + return path; + } else { + return path + ":" + exportName; + } + } else if (prop === Symbol.toStringTag) { + return "FunctionReference"; + } else { + return void 0; + } + } + }; + return new Proxy({}, handler); + } + var anyApi = createApi(); + + // src/browser/sync/optimistic_updates_impl.ts + var OptimisticLocalStoreImpl = class _OptimisticLocalStoreImpl { + // A references of the query results in OptimisticQueryResults + queryResults; + // All of the queries modified by this class + modifiedQueries; + constructor(queryResults) { + this.queryResults = queryResults; + this.modifiedQueries = []; + } + getQuery(query, ...args) { + const queryArgs = parseArgs(args[0]); + const name = getFunctionName(query); + const queryResult = this.queryResults.get( + serializePathAndArgs(name, queryArgs) + ); + if (queryResult === void 0) { + return void 0; + } + return _OptimisticLocalStoreImpl.queryValue(queryResult.result); + } + getAllQueries(query) { + const queriesWithName = []; + const name = getFunctionName(query); + for (const queryResult of this.queryResults.values()) { + if (queryResult.udfPath === canonicalizeUdfPath(name)) { + queriesWithName.push({ + args: queryResult.args, + value: _OptimisticLocalStoreImpl.queryValue(queryResult.result) + }); + } + } + return queriesWithName; + } + setQuery(queryReference, args, value) { + const queryArgs = parseArgs(args); + const name = getFunctionName(queryReference); + const queryToken = serializePathAndArgs(name, queryArgs); + let result; + if (value === void 0) { + result = void 0; + } else { + result = { + success: true, + value, + // It's an optimistic update, so there are no function logs to show. + logLines: [] + }; + } + const query = { + udfPath: name, + args: queryArgs, + result + }; + this.queryResults.set(queryToken, query); + this.modifiedQueries.push(queryToken); + } + static queryValue(result) { + if (result === void 0) { + return void 0; + } else if (result.success) { + return result.value; + } else { + return void 0; + } + } + }; + var OptimisticQueryResults = class { + queryResults; + optimisticUpdates; + constructor() { + this.queryResults = /* @__PURE__ */ new Map(); + this.optimisticUpdates = []; + } + /** + * Apply all optimistic updates on top of server query results + */ + ingestQueryResultsFromServer(serverQueryResults, optimisticUpdatesToDrop) { + this.optimisticUpdates = this.optimisticUpdates.filter((updateAndId) => { + return !optimisticUpdatesToDrop.has(updateAndId.mutationId); + }); + const oldQueryResults = this.queryResults; + this.queryResults = new Map(serverQueryResults); + const localStore = new OptimisticLocalStoreImpl(this.queryResults); + for (const updateAndId of this.optimisticUpdates) { + updateAndId.update(localStore); + } + const changedQueries = []; + for (const [queryToken, query] of this.queryResults) { + const oldQuery = oldQueryResults.get(queryToken); + if (oldQuery === void 0 || oldQuery.result !== query.result) { + changedQueries.push(queryToken); + } + } + return changedQueries; + } + applyOptimisticUpdate(update, mutationId) { + this.optimisticUpdates.push({ + update, + mutationId + }); + const localStore = new OptimisticLocalStoreImpl(this.queryResults); + update(localStore); + return localStore.modifiedQueries; + } + /** + * "Raw" with respect to errors vs values, but query results still have + * optimistic updates applied. + * + * @internal + */ + rawQueryResult(queryToken) { + const query = this.queryResults.get(queryToken); + if (query === void 0) { + return void 0; + } + return query.result; + } + queryResult(queryToken) { + const query = this.queryResults.get(queryToken); + if (query === void 0) { + return void 0; + } + const result = query.result; + if (result === void 0) { + return void 0; + } else if (result.success) { + return result.value; + } else { + if (result.errorData !== void 0) { + throw forwardData( + result, + new ConvexError( + createHybridErrorStacktrace("query", query.udfPath, result) + ) + ); + } + throw new Error( + createHybridErrorStacktrace("query", query.udfPath, result) + ); + } + } + hasQueryResult(queryToken) { + return this.queryResults.get(queryToken) !== void 0; + } + /** + * @internal + */ + queryLogs(queryToken) { + const query = this.queryResults.get(queryToken); + return query?.result?.logLines; + } + }; + + // src/vendor/long.ts + var Long = class _Long { + low; + high; + __isUnsignedLong__; + static isLong(obj) { + return (obj && obj.__isUnsignedLong__) === true; + } + constructor(low, high) { + this.low = low | 0; + this.high = high | 0; + this.__isUnsignedLong__ = true; + } + // prettier-ignore + static fromBytesLE(bytes) { + return new _Long( + bytes[0] | bytes[1] << 8 | bytes[2] << 16 | bytes[3] << 24, + bytes[4] | bytes[5] << 8 | bytes[6] << 16 | bytes[7] << 24 + ); + } + // prettier-ignore + toBytesLE() { + const hi = this.high; + const lo = this.low; + return [ + lo & 255, + lo >>> 8 & 255, + lo >>> 16 & 255, + lo >>> 24, + hi & 255, + hi >>> 8 & 255, + hi >>> 16 & 255, + hi >>> 24 + ]; + } + static fromNumber(value) { + if (isNaN(value)) return UZERO; + if (value < 0) return UZERO; + if (value >= TWO_PWR_64_DBL) return MAX_UNSIGNED_VALUE; + return new _Long(value % TWO_PWR_32_DBL | 0, value / TWO_PWR_32_DBL | 0); + } + toString() { + return (BigInt(this.high) * BigInt(TWO_PWR_32_DBL) + BigInt(this.low)).toString(); + } + equals(other) { + if (!_Long.isLong(other)) other = _Long.fromValue(other); + if (this.high >>> 31 === 1 && other.high >>> 31 === 1) return false; + return this.high === other.high && this.low === other.low; + } + notEquals(other) { + return !this.equals(other); + } + comp(other) { + if (!_Long.isLong(other)) other = _Long.fromValue(other); + if (this.equals(other)) return 0; + return other.high >>> 0 > this.high >>> 0 || other.high === this.high && other.low >>> 0 > this.low >>> 0 ? -1 : 1; + } + lessThanOrEqual(other) { + return this.comp( + /* validates */ + other + ) <= 0; + } + static fromValue(val) { + if (typeof val === "number") return _Long.fromNumber(val); + return new _Long(val.low, val.high); + } + }; + var UZERO = new Long(0, 0); + var TWO_PWR_16_DBL = 1 << 16; + var TWO_PWR_32_DBL = TWO_PWR_16_DBL * TWO_PWR_16_DBL; + var TWO_PWR_64_DBL = TWO_PWR_32_DBL * TWO_PWR_32_DBL; + var MAX_UNSIGNED_VALUE = new Long(4294967295 | 0, 4294967295 | 0); + + // src/browser/sync/remote_query_set.ts + var RemoteQuerySet = class { + version; + remoteQuerySet; + queryPath; + logger; + constructor(queryPath, logger) { + this.version = { querySet: 0, ts: Long.fromNumber(0), identity: 0 }; + this.remoteQuerySet = /* @__PURE__ */ new Map(); + this.queryPath = queryPath; + this.logger = logger; + } + transition(transition) { + const start = transition.startVersion; + if (this.version.querySet !== start.querySet || this.version.ts.notEquals(start.ts) || this.version.identity !== start.identity) { + throw new Error( + `Invalid start version: ${start.ts.toString()}:${start.querySet}:${start.identity}, transitioning from ${this.version.ts.toString()}:${this.version.querySet}:${this.version.identity}` + ); + } + for (const modification of transition.modifications) { + switch (modification.type) { + case "QueryUpdated": { + const queryPath = this.queryPath(modification.queryId); + if (queryPath) { + for (const line of modification.logLines) { + logForFunction(this.logger, "info", "query", queryPath, line); + } + } + const value = jsonToConvex(modification.value ?? null); + this.remoteQuerySet.set(modification.queryId, { + success: true, + value, + logLines: modification.logLines + }); + break; + } + case "QueryFailed": { + const queryPath = this.queryPath(modification.queryId); + if (queryPath) { + for (const line of modification.logLines) { + logForFunction(this.logger, "info", "query", queryPath, line); + } + } + const { errorData } = modification; + this.remoteQuerySet.set(modification.queryId, { + success: false, + errorMessage: modification.errorMessage, + errorData: errorData !== void 0 ? jsonToConvex(errorData) : void 0, + logLines: modification.logLines + }); + break; + } + case "QueryRemoved": { + this.remoteQuerySet.delete(modification.queryId); + break; + } + default: { + modification; + throw new Error(`Invalid modification ${modification.type}`); + } + } + } + this.version = transition.endVersion; + } + remoteQueryResults() { + return this.remoteQuerySet; + } + timestamp() { + return this.version.ts; + } + }; + + // src/browser/sync/protocol.ts + function u64ToLong(encoded) { + const integerBytes = base64_exports.toByteArray(encoded); + return Long.fromBytesLE(Array.from(integerBytes)); + } + function longToU64(raw) { + const integerBytes = new Uint8Array(raw.toBytesLE()); + return base64_exports.fromByteArray(integerBytes); + } + function parseServerMessage(encoded) { + switch (encoded.type) { + case "FatalError": + case "AuthError": + case "ActionResponse": + case "TransitionChunk": + case "Ping": { + return { ...encoded }; + } + case "MutationResponse": { + if (encoded.success) { + return { ...encoded, ts: u64ToLong(encoded.ts) }; + } else { + return { ...encoded }; + } + } + case "Transition": { + return { + ...encoded, + startVersion: { + ...encoded.startVersion, + ts: u64ToLong(encoded.startVersion.ts) + }, + endVersion: { + ...encoded.endVersion, + ts: u64ToLong(encoded.endVersion.ts) + } + }; + } + default: { + encoded; + } + } + return void 0; + } + function encodeClientMessage(message) { + switch (message.type) { + case "Authenticate": + case "ModifyQuerySet": + case "Mutation": + case "Action": + case "Event": { + return { ...message }; + } + case "Connect": { + if (message.maxObservedTimestamp !== void 0) { + return { + ...message, + maxObservedTimestamp: longToU64(message.maxObservedTimestamp) + }; + } else { + return { ...message, maxObservedTimestamp: void 0 }; + } + } + default: { + message; + } + } + return void 0; + } + + // src/browser/sync/web_socket_manager.ts + var CLOSE_NORMAL = 1e3; + var CLOSE_GOING_AWAY = 1001; + var CLOSE_NO_STATUS = 1005; + var CLOSE_NOT_FOUND = 4040; + var firstTime; + function monotonicMillis() { + if (firstTime === void 0) { + firstTime = Date.now(); + } + if (typeof performance === "undefined" || !performance.now) { + return Date.now(); + } + return Math.round(firstTime + performance.now()); + } + function prettyNow() { + return `t=${Math.round((monotonicMillis() - firstTime) / 100) / 10}s`; + } + var serverDisconnectErrors = { + // A known error, e.g. during a restart or push + InternalServerError: { timeout: 1e3 }, + // ErrorMetadata::overloaded() messages that we realy should back off + SubscriptionsWorkerFullError: { timeout: 3e3 }, + TooManyConcurrentRequests: { timeout: 3e3 }, + CommitterFullError: { timeout: 3e3 }, + AwsTooManyRequestsException: { timeout: 3e3 }, + ExecuteFullError: { timeout: 3e3 }, + SystemTimeoutError: { timeout: 3e3 }, + ExpiredInQueue: { timeout: 3e3 }, + // ErrorMetadata::feature_temporarily_unavailable() that typically indicate a deploy just happened + VectorIndexesUnavailable: { timeout: 1e3 }, + SearchIndexesUnavailable: { timeout: 1e3 }, + TableSummariesUnavailable: { timeout: 1e3 }, + // More ErrorMetadata::overloaded() + VectorIndexTooLarge: { timeout: 3e3 }, + SearchIndexTooLarge: { timeout: 3e3 }, + TooManyWritesInTimePeriod: { timeout: 3e3 } + }; + function classifyDisconnectError(s) { + if (s === void 0) return "Unknown"; + for (const prefix of Object.keys( + serverDisconnectErrors + )) { + if (s.startsWith(prefix)) { + return prefix; + } + } + return "Unknown"; + } + var WebSocketManager = class { + constructor(uri, callbacks, webSocketConstructor, logger, markConnectionStateDirty, debug) { + this.markConnectionStateDirty = markConnectionStateDirty; + this.debug = debug; + this.webSocketConstructor = webSocketConstructor; + this.socket = { state: "disconnected" }; + this.connectionCount = 0; + this.lastCloseReason = "InitialConnect"; + this.defaultInitialBackoff = 1e3; + this.maxBackoff = 16e3; + this.retries = 0; + this.serverInactivityThreshold = 6e4; + this.reconnectDueToServerInactivityTimeout = null; + this.uri = uri; + this.onOpen = callbacks.onOpen; + this.onResume = callbacks.onResume; + this.onMessage = callbacks.onMessage; + this.onServerDisconnectError = callbacks.onServerDisconnectError; + this.logger = logger; + this.setupNetworkListener(); + this.connect(); + } + socket; + connectionCount; + _hasEverConnected = false; + lastCloseReason; + // State for assembling the split-up Transition currently being received. + transitionChunkBuffer = null; + /** Upon HTTPS/WSS failure, the first jittered backoff duration, in ms. */ + defaultInitialBackoff; + /** We backoff exponentially, but we need to cap that--this is the jittered max. */ + maxBackoff; + /** How many times have we failed consecutively? */ + retries; + /** How long before lack of server response causes us to initiate a reconnect, + * in ms */ + serverInactivityThreshold; + reconnectDueToServerInactivityTimeout; + /** Scheduled reconnect state: timeout handle and timing info */ + scheduledReconnect = null; + networkOnlineHandler = null; + /** Pending event to send after reconnecting due to network recovery */ + pendingNetworkRecoveryInfo = null; + uri; + onOpen; + onResume; + onMessage; + webSocketConstructor; + logger; + onServerDisconnectError; + setSocketState(state) { + this.socket = state; + this._logVerbose( + `socket state changed: ${this.socket.state}, paused: ${"paused" in this.socket ? this.socket.paused : void 0}` + ); + this.markConnectionStateDirty(); + } + setupNetworkListener() { + if (typeof window === "undefined" || typeof window.addEventListener !== "function") { + return; + } + if (this.networkOnlineHandler !== null) { + return; + } + this.networkOnlineHandler = () => { + this._logVerbose("network online event detected"); + this.tryReconnectImmediately(); + }; + window.addEventListener("online", this.networkOnlineHandler); + this._logVerbose("network online event listener registered"); + } + cleanupNetworkListener() { + if (this.networkOnlineHandler && typeof window !== "undefined" && typeof window.removeEventListener === "function") { + window.removeEventListener("online", this.networkOnlineHandler); + this.networkOnlineHandler = null; + this._logVerbose("network online event listener removed"); + } + } + assembleTransition(chunk) { + if (chunk.partNumber < 0 || chunk.partNumber >= chunk.totalParts || chunk.totalParts === 0 || this.transitionChunkBuffer && (this.transitionChunkBuffer.totalParts !== chunk.totalParts || this.transitionChunkBuffer.transitionId !== chunk.transitionId)) { + this.transitionChunkBuffer = null; + throw new Error("Invalid TransitionChunk"); + } + if (this.transitionChunkBuffer === null) { + this.transitionChunkBuffer = { + chunks: [], + totalParts: chunk.totalParts, + transitionId: chunk.transitionId + }; + } + if (chunk.partNumber !== this.transitionChunkBuffer.chunks.length) { + const expectedLength = this.transitionChunkBuffer.chunks.length; + this.transitionChunkBuffer = null; + throw new Error( + `TransitionChunk received out of order: expected part ${expectedLength}, got ${chunk.partNumber}` + ); + } + this.transitionChunkBuffer.chunks.push(chunk.chunk); + if (this.transitionChunkBuffer.chunks.length === chunk.totalParts) { + const fullJson = this.transitionChunkBuffer.chunks.join(""); + this.transitionChunkBuffer = null; + const transition = parseServerMessage(JSON.parse(fullJson)); + if (transition.type !== "Transition") { + throw new Error( + `Expected Transition, got ${transition.type} after assembling chunks` + ); + } + return transition; + } + return null; + } + connect() { + if (this.socket.state === "terminated") { + return; + } + if (this.socket.state !== "disconnected" && this.socket.state !== "stopped") { + throw new Error( + "Didn't start connection from disconnected state: " + this.socket.state + ); + } + const ws = new this.webSocketConstructor(this.uri); + this._logVerbose("constructed WebSocket"); + this.setSocketState({ + state: "connecting", + ws, + paused: "no" + }); + this.resetServerInactivityTimeout(); + ws.onopen = () => { + this.logger.logVerbose("begin ws.onopen"); + if (this.socket.state !== "connecting") { + throw new Error("onopen called with socket not in connecting state"); + } + this.setSocketState({ + state: "ready", + ws, + paused: this.socket.paused === "yes" ? "uninitialized" : "no" + }); + this.resetServerInactivityTimeout(); + if (this.socket.paused === "no") { + this._hasEverConnected = true; + this.onOpen({ + connectionCount: this.connectionCount, + lastCloseReason: this.lastCloseReason, + clientTs: monotonicMillis() + }); + } + if (this.lastCloseReason !== "InitialConnect") { + if (this.lastCloseReason) { + this.logger.log( + "WebSocket reconnected at", + prettyNow(), + "after disconnect due to", + this.lastCloseReason + ); + } else { + this.logger.log("WebSocket reconnected at", prettyNow()); + } + } + this.connectionCount += 1; + this.lastCloseReason = null; + if (this.pendingNetworkRecoveryInfo !== null) { + const { timeSavedMs } = this.pendingNetworkRecoveryInfo; + this.pendingNetworkRecoveryInfo = null; + this.sendMessage({ + type: "Event", + eventType: "NetworkRecoveryReconnect", + event: { timeSavedMs } + }); + this.logger.log( + `Network recovery reconnect saved ~${Math.round(timeSavedMs / 1e3)}s of waiting` + ); + } + }; + ws.onerror = (error) => { + this.transitionChunkBuffer = null; + const message = error.message; + if (message) { + this.logger.log(`WebSocket error message: ${message}`); + } + }; + ws.onmessage = (message) => { + this.resetServerInactivityTimeout(); + const messageLength = message.data.length; + let serverMessage = parseServerMessage(JSON.parse(message.data)); + this._logVerbose(`received ws message with type ${serverMessage.type}`); + if (serverMessage.type === "Ping") { + return; + } + if (serverMessage.type === "TransitionChunk") { + const transition = this.assembleTransition(serverMessage); + if (!transition) { + return; + } + serverMessage = transition; + this._logVerbose( + `assembled full ws message of type ${serverMessage.type}` + ); + } + if (this.transitionChunkBuffer !== null) { + this.transitionChunkBuffer = null; + this.logger.log( + `Received unexpected ${serverMessage.type} while buffering TransitionChunks` + ); + } + if (serverMessage.type === "Transition") { + this.reportLargeTransition({ + messageLength, + transition: serverMessage + }); + } + const response = this.onMessage(serverMessage); + if (response.hasSyncedPastLastReconnect) { + this.retries = 0; + this.markConnectionStateDirty(); + } + }; + ws.onclose = (event) => { + this._logVerbose("begin ws.onclose"); + this.transitionChunkBuffer = null; + if (this.lastCloseReason === null) { + this.lastCloseReason = event.reason || `closed with code ${event.code}`; + } + if (event.code !== CLOSE_NORMAL && event.code !== CLOSE_GOING_AWAY && // This commonly gets fired on mobile apps when the app is backgrounded + event.code !== CLOSE_NO_STATUS && event.code !== CLOSE_NOT_FOUND) { + let msg = `WebSocket closed with code ${event.code}`; + if (event.reason) { + msg += `: ${event.reason}`; + } + this.logger.log(msg); + if (this.onServerDisconnectError && event.reason) { + this.onServerDisconnectError(msg); + } + } + const reason = classifyDisconnectError(event.reason); + this.scheduleReconnect(reason); + return; + }; + } + /** + * @returns The state of the {@link Socket}. + */ + socketState() { + return this.socket.state; + } + /** + * @param message - A ClientMessage to send. + * @returns Whether the message (might have been) sent. + */ + sendMessage(message) { + const messageForLog = { + type: message.type, + ...message.type === "Authenticate" && message.tokenType === "User" ? { + value: `...${message.value.slice(-7)}` + } : {} + }; + if (this.socket.state === "ready" && this.socket.paused === "no") { + const encodedMessage = encodeClientMessage(message); + const request = JSON.stringify(encodedMessage); + let sent = false; + try { + this.socket.ws.send(request); + sent = true; + } catch (error) { + this.logger.log( + `Failed to send message on WebSocket, reconnecting: ${error}` + ); + this.closeAndReconnect("FailedToSendMessage"); + } + this._logVerbose( + `${sent ? "sent" : "failed to send"} message with type ${message.type}: ${JSON.stringify( + messageForLog + )}` + ); + return true; + } + this._logVerbose( + `message not sent (socket state: ${this.socket.state}, paused: ${"paused" in this.socket ? this.socket.paused : void 0}): ${JSON.stringify( + messageForLog + )}` + ); + return false; + } + resetServerInactivityTimeout() { + if (this.socket.state === "terminated") { + return; + } + if (this.reconnectDueToServerInactivityTimeout !== null) { + clearTimeout(this.reconnectDueToServerInactivityTimeout); + this.reconnectDueToServerInactivityTimeout = null; + } + this.reconnectDueToServerInactivityTimeout = setTimeout(() => { + this.closeAndReconnect("InactiveServer"); + }, this.serverInactivityThreshold); + } + scheduleReconnect(reason) { + if (this.scheduledReconnect) { + clearTimeout(this.scheduledReconnect.timeout); + this.scheduledReconnect = null; + } + this.socket = { state: "disconnected" }; + const backoff = this.nextBackoff(reason); + this.markConnectionStateDirty(); + this.logger.log(`Attempting reconnect in ${Math.round(backoff)}ms`); + const scheduledAt = monotonicMillis(); + const timeoutId = setTimeout(() => { + if (this.scheduledReconnect?.timeout === timeoutId) { + this.scheduledReconnect = null; + this.connect(); + } + }, backoff); + this.scheduledReconnect = { + timeout: timeoutId, + scheduledAt, + backoffMs: backoff + }; + } + /** + * Close the WebSocket and schedule a reconnect. + * + * This should be used when we hit an error and would like to restart the session. + */ + closeAndReconnect(closeReason) { + this._logVerbose(`begin closeAndReconnect with reason ${closeReason}`); + switch (this.socket.state) { + case "disconnected": + case "terminated": + case "stopped": + return; + case "connecting": + case "ready": { + this.lastCloseReason = closeReason; + void this.close(); + this.scheduleReconnect("client"); + return; + } + default: { + this.socket; + } + } + } + /** + * Close the WebSocket, being careful to clear the onclose handler to avoid re-entrant + * calls. Use this instead of directly calling `ws.close()` + * + * It is the callers responsibility to update the state after this method is called so that the + * closed socket is not accessible or used again after this method is called + */ + close() { + this.transitionChunkBuffer = null; + switch (this.socket.state) { + case "disconnected": + case "terminated": + case "stopped": + return Promise.resolve(); + case "connecting": { + const ws = this.socket.ws; + ws.onmessage = (_message) => { + this._logVerbose("Ignoring message received after close"); + }; + return new Promise((r) => { + ws.onclose = () => { + this._logVerbose("Closed after connecting"); + r(); + }; + ws.onopen = () => { + this._logVerbose("Opened after connecting"); + ws.close(); + }; + }); + } + case "ready": { + this._logVerbose("ws.close called"); + const ws = this.socket.ws; + ws.onmessage = (_message) => { + this._logVerbose("Ignoring message received after close"); + }; + const result = new Promise((r) => { + ws.onclose = () => { + r(); + }; + }); + ws.close(); + return result; + } + default: { + this.socket; + return Promise.resolve(); + } + } + } + /** + * Close the WebSocket and do not reconnect. + * @returns A Promise that resolves when the WebSocket `onClose` callback is called. + */ + terminate() { + if (this.reconnectDueToServerInactivityTimeout) { + clearTimeout(this.reconnectDueToServerInactivityTimeout); + } + if (this.scheduledReconnect) { + clearTimeout(this.scheduledReconnect.timeout); + this.scheduledReconnect = null; + } + this.cleanupNetworkListener(); + switch (this.socket.state) { + case "terminated": + case "stopped": + case "disconnected": + case "connecting": + case "ready": { + const result = this.close(); + this.setSocketState({ state: "terminated" }); + return result; + } + default: { + this.socket; + throw new Error( + `Invalid websocket state: ${this.socket.state}` + ); + } + } + } + stop() { + switch (this.socket.state) { + case "terminated": + return Promise.resolve(); + case "connecting": + case "stopped": + case "disconnected": + case "ready": { + this.cleanupNetworkListener(); + const result = this.close(); + this.socket = { state: "stopped" }; + return result; + } + default: { + this.socket; + return Promise.resolve(); + } + } + } + /** + * Create a new WebSocket after a previous `stop()`, unless `terminate()` was + * called before. + */ + tryRestart() { + switch (this.socket.state) { + case "stopped": + break; + case "terminated": + case "connecting": + case "ready": + case "disconnected": + this.logger.logVerbose("Restart called without stopping first"); + return; + default: { + this.socket; + } + } + this.setupNetworkListener(); + this.connect(); + } + pause() { + switch (this.socket.state) { + case "disconnected": + case "stopped": + case "terminated": + return; + case "connecting": + case "ready": { + this.socket = { ...this.socket, paused: "yes" }; + return; + } + default: { + this.socket; + return; + } + } + } + /** + * Try to reconnect immediately, canceling any scheduled reconnect. + * This is useful when detecting network recovery. + * Only takes action if we're in disconnected state (waiting to reconnect). + */ + tryReconnectImmediately() { + this._logVerbose("tryReconnectImmediately called"); + if (this.socket.state !== "disconnected") { + this._logVerbose( + `tryReconnectImmediately called but socket state is ${this.socket.state}, no action taken` + ); + return; + } + let timeSavedMs = null; + if (this.scheduledReconnect) { + const elapsed = monotonicMillis() - this.scheduledReconnect.scheduledAt; + timeSavedMs = Math.max(0, this.scheduledReconnect.backoffMs - elapsed); + this._logVerbose( + `would have waited ${Math.round(timeSavedMs)}ms more (backoff was ${Math.round(this.scheduledReconnect.backoffMs)}ms, elapsed ${Math.round(elapsed)}ms)` + ); + clearTimeout(this.scheduledReconnect.timeout); + this.scheduledReconnect = null; + this._logVerbose("canceled scheduled reconnect"); + } + this.logger.log("Network recovery detected, reconnecting immediately"); + this.pendingNetworkRecoveryInfo = timeSavedMs !== null ? { timeSavedMs } : null; + this.connect(); + } + /** + * Resume the state machine if previously paused. + */ + resume() { + switch (this.socket.state) { + case "connecting": + this.socket = { ...this.socket, paused: "no" }; + return; + case "ready": + if (this.socket.paused === "uninitialized") { + this.socket = { ...this.socket, paused: "no" }; + this._hasEverConnected = true; + this.onOpen({ + connectionCount: this.connectionCount, + lastCloseReason: this.lastCloseReason, + clientTs: monotonicMillis() + }); + } else if (this.socket.paused === "yes") { + this.socket = { ...this.socket, paused: "no" }; + this.onResume(); + } + return; + case "terminated": + case "stopped": + case "disconnected": + return; + default: { + this.socket; + } + } + this.connect(); + } + connectionState() { + return { + isConnected: this.socket.state === "ready", + hasEverConnected: this._hasEverConnected, + connectionCount: this.connectionCount, + connectionRetries: this.retries + }; + } + _logVerbose(message) { + this.logger.logVerbose(message); + } + nextBackoff(reason) { + const initialBackoff = reason === "client" ? 100 : reason === "Unknown" ? this.defaultInitialBackoff : serverDisconnectErrors[reason].timeout; + const baseBackoff = initialBackoff * Math.pow(2, this.retries); + this.retries += 1; + const actualBackoff = Math.min(baseBackoff, this.maxBackoff); + const jitter = actualBackoff * (Math.random() - 0.5); + return actualBackoff + jitter; + } + reportLargeTransition({ + transition, + messageLength + }) { + if (transition.clientClockSkew === void 0 || transition.serverTs === void 0) { + return; + } + const transitionTransitTime = monotonicMillis() - // client time now + // clientClockSkew = (server time + upstream latency) - client time + // clientClockSkew is "how many milliseconds behind (slow) is the client clock" + // but the latency of the Connect message inflates this, making it appear further behind + transition.clientClockSkew - transition.serverTs / 1e6; + const prettyTransitionTime = `${Math.round(transitionTransitTime)}ms`; + const prettyMessageMB = `${Math.round(messageLength / 1e4) / 100}MB`; + const bytesPerSecond = messageLength / (transitionTransitTime / 1e3); + const prettyBytesPerSecond = `${Math.round(bytesPerSecond / 1e4) / 100}MB per second`; + this._logVerbose( + `received ${prettyMessageMB} transition in ${prettyTransitionTime} at ${prettyBytesPerSecond}` + ); + if (messageLength > 2e7) { + this.logger.log( + `received query results totaling more that 20MB (${prettyMessageMB}) which will take a long time to download on slower connections` + ); + } else if (transitionTransitTime > 2e4) { + this.logger.log( + `received query results totaling ${prettyMessageMB} which took more than 20s to arrive (${prettyTransitionTime})` + ); + } + if (this.debug) { + this.sendMessage({ + type: "Event", + eventType: "ClientReceivedTransition", + event: { transitionTransitTime, messageLength } + }); + } + } + }; + + // src/browser/sync/session.ts + function newSessionId() { + return uuidv4(); + } + function uuidv4() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0, v = c === "x" ? r : r & 3 | 8; + return v.toString(16); + }); + } + + // src/vendor/jwt-decode/index.ts + var InvalidTokenError = class extends Error { + }; + InvalidTokenError.prototype.name = "InvalidTokenError"; + function b64DecodeUnicode(str) { + return decodeURIComponent( + atob(str).replace(/(.)/g, (_m, p) => { + let code2 = p.charCodeAt(0).toString(16).toUpperCase(); + if (code2.length < 2) { + code2 = "0" + code2; + } + return "%" + code2; + }) + ); + } + function base64UrlDecode(str) { + let output = str.replace(/-/g, "+").replace(/_/g, "/"); + switch (output.length % 4) { + case 0: + break; + case 2: + output += "=="; + break; + case 3: + output += "="; + break; + default: + throw new Error("base64 string is not of the correct length"); + } + try { + return b64DecodeUnicode(output); + } catch { + return atob(output); + } + } + function jwtDecode(token, options) { + if (typeof token !== "string") { + throw new InvalidTokenError("Invalid token specified: must be a string"); + } + options ||= {}; + const pos = options.header === true ? 0 : 1; + const part = token.split(".")[pos]; + if (typeof part !== "string") { + throw new InvalidTokenError( + `Invalid token specified: missing part #${pos + 1}` + ); + } + let decoded; + try { + decoded = base64UrlDecode(part); + } catch (e) { + throw new InvalidTokenError( + `Invalid token specified: invalid base64 for part #${pos + 1} (${e.message})` + ); + } + try { + return JSON.parse(decoded); + } catch (e) { + throw new InvalidTokenError( + `Invalid token specified: invalid json for part #${pos + 1} (${e.message})` + ); + } + } + + // src/browser/sync/authentication_manager.ts + var MAXIMUM_REFRESH_DELAY = 20 * 24 * 60 * 60 * 1e3; + var MAX_TOKEN_CONFIRMATION_ATTEMPTS = 2; + var AuthenticationManager = class { + authState = { state: "noAuth" }; + // Used to detect races involving `setConfig` calls + // while a token is being fetched. + configVersion = 0; + // Shared by the BaseClient so that the auth manager can easily inspect it + syncState; + // Passed down by BaseClient, sends a message to the server + authenticate; + stopSocket; + tryRestartSocket; + pauseSocket; + resumeSocket; + // Passed down by BaseClient, sends a message to the server + clearAuth; + logger; + refreshTokenLeewaySeconds; + initialAuthTokenReuse; + // Track last value to avoid redundant calls + lastRefreshChange; + // Number of times we have attempted to confirm the latest token. We retry up + // to `MAX_TOKEN_CONFIRMATION_ATTEMPTS` times. + tokenConfirmationAttempts = 0; + constructor(syncState, callbacks, config) { + this.syncState = syncState; + this.authenticate = callbacks.authenticate; + this.stopSocket = callbacks.stopSocket; + this.tryRestartSocket = callbacks.tryRestartSocket; + this.pauseSocket = callbacks.pauseSocket; + this.resumeSocket = callbacks.resumeSocket; + this.clearAuth = callbacks.clearAuth; + this.logger = config.logger; + this.refreshTokenLeewaySeconds = config.refreshTokenLeewaySeconds; + this.initialAuthTokenReuse = config.initialAuthTokenReuse; + this.lastRefreshChange = false; + } + notifyRefreshChange(isRefreshing) { + if (this.authState.state !== "noAuth" && this.authState.state !== "initialRefetch" && this.authState.config.onRefreshChange && this.lastRefreshChange !== isRefreshing) { + this.lastRefreshChange = isRefreshing; + this.authState.config.onRefreshChange(isRefreshing); + } + } + async setConfig(fetchToken, onChange, onRefreshChange) { + this.resetAuthState(); + this._logVerbose("pausing WS for auth token fetch"); + this.pauseSocket(); + const token = await this.fetchTokenAndGuardAgainstRace(fetchToken, { + forceRefreshToken: false + }); + if (token.isFromOutdatedConfig) { + return; + } + const config = { + fetchToken, + onAuthChange: onChange, + onRefreshChange + }; + if (token.value) { + this.setAuthState({ + state: "waitingForServerConfirmationOfCachedToken", + config, + hasRetried: false + }); + this.authenticate(token.value); + } else { + this.setAuthState({ + state: "initialRefetch", + config + }); + await this.refetchToken(); + } + this._logVerbose("resuming WS after auth token fetch"); + this.resumeSocket(); + } + onTransition(serverMessage) { + if (!this.syncState.isCurrentOrNewerAuthVersion( + serverMessage.endVersion.identity + )) { + return; + } + if (serverMessage.endVersion.identity <= serverMessage.startVersion.identity) { + return; + } + this._logVerbose( + `auth state is ${this.authState.state} when handling transition` + ); + this.syncState.markAuthCompletion(); + if (this.authState.state === "waitingForServerConfirmationOfCachedToken") { + this._logVerbose("server confirmed auth token is valid"); + const cachedToken = this.syncState.getAuth()?.value; + if (this.initialAuthTokenReuse && cachedToken) { + this.scheduleTokenRefetch(cachedToken, serverMessage.clientClockSkew); + } else { + void this.refetchToken(); + } + this.authState.config.onAuthChange(true); + return; + } + if (this.authState.state === "waitingForServerConfirmationOfFreshToken") { + this._logVerbose("server confirmed new auth token is valid"); + this.notifyRefreshChange(false); + this.scheduleTokenRefetch(this.authState.token); + this.tokenConfirmationAttempts = 0; + if (!this.authState.hadAuth) { + this.authState.config.onAuthChange(true); + } + } + } + onAuthError(serverMessage) { + if (serverMessage.authUpdateAttempted === false && (this.authState.state === "waitingForServerConfirmationOfFreshToken" || this.authState.state === "waitingForServerConfirmationOfCachedToken")) { + this._logVerbose("ignoring non-auth token expired error"); + return; + } + const { baseVersion } = serverMessage; + if (!this.syncState.isCurrentOrNewerAuthVersion(baseVersion + 1)) { + this._logVerbose("ignoring auth error for previous auth attempt"); + return; + } + void this.tryToReauthenticate(serverMessage); + return; + } + // This is similar to `refetchToken` defined below, in fact we + // don't represent them as different states, but it is different + // in that we pause the WebSocket so that mutations + // don't retry with bad auth. + async tryToReauthenticate(serverMessage) { + this._logVerbose(`attempting to reauthenticate: ${serverMessage.error}`); + if ( + // No way to fetch another token, kaboom + this.authState.state === "noAuth" || // We failed on a fresh token. After a small number of retries, we give up + // and clear the auth state to avoid infinite retries. + this.authState.state === "waitingForServerConfirmationOfFreshToken" && this.tokenConfirmationAttempts >= MAX_TOKEN_CONFIRMATION_ATTEMPTS + ) { + this.logger.error( + `Failed to authenticate: "${serverMessage.error}", check your server auth config` + ); + if (this.syncState.hasAuth()) { + this.syncState.clearAuth(); + } + if (this.authState.state !== "noAuth") { + this.setAndReportAuthFailed(this.authState.config.onAuthChange); + } + return; + } + if (this.authState.state === "waitingForServerConfirmationOfFreshToken") { + this.tokenConfirmationAttempts++; + this._logVerbose( + `retrying reauthentication, ${MAX_TOKEN_CONFIRMATION_ATTEMPTS - this.tokenConfirmationAttempts} attempts remaining` + ); + } + this.notifyRefreshChange(true); + await this.stopSocket(); + if (this.authState.state === "noAuth") { + return; + } + const token = await this.fetchTokenAndGuardAgainstRace( + this.authState.config.fetchToken, + { + forceRefreshToken: true + } + ); + if (token.isFromOutdatedConfig) { + return; + } + if (token.value && this.syncState.isNewAuth(token.value)) { + this.authenticate(token.value); + this.setAuthState({ + state: "waitingForServerConfirmationOfFreshToken", + config: this.authState.config, + token: token.value, + hadAuth: this.authState.state === "notRefetching" || this.authState.state === "waitingForScheduledRefetch" + }); + } else { + this._logVerbose("reauthentication failed, could not fetch a new token"); + if (this.syncState.hasAuth()) { + this.syncState.clearAuth(); + } + this.setAndReportAuthFailed(this.authState.config.onAuthChange); + } + this.tryRestartSocket(); + } + // Force refetch the token and schedule another refetch + // before the token expires - an active client should never + // need to reauthenticate. + async refetchToken() { + if (this.authState.state === "noAuth") { + return; + } + this._logVerbose("refetching auth token"); + const token = await this.fetchTokenAndGuardAgainstRace( + this.authState.config.fetchToken, + { + forceRefreshToken: true + } + ); + if (token.isFromOutdatedConfig) { + return; + } + if (token.value) { + if (this.syncState.isNewAuth(token.value)) { + this.setAuthState({ + state: "waitingForServerConfirmationOfFreshToken", + hadAuth: this.syncState.hasAuth(), + token: token.value, + config: this.authState.config + }); + this.authenticate(token.value); + } else { + this.setAuthState({ + state: "notRefetching", + config: this.authState.config + }); + } + } else { + this._logVerbose("refetching token failed"); + if (this.syncState.hasAuth()) { + this.clearAuth(); + } + this.setAndReportAuthFailed(this.authState.config.onAuthChange); + } + this._logVerbose( + "restarting WS after auth token fetch (if currently stopped)" + ); + this.tryRestartSocket(); + } + scheduleTokenRefetch(token, clientClockSkewMs) { + if (this.authState.state === "noAuth") { + return; + } + const decodedToken = this.decodeToken(token); + if (!decodedToken) { + this.logger.error( + "Auth token is not a valid JWT, cannot refetch the token" + ); + return; + } + const { iat, exp } = decodedToken; + if (!iat || !exp) { + this.logger.error( + "Auth token does not have required fields, cannot refetch the token" + ); + return; + } + const fullLifetimeSeconds = exp - iat; + if (fullLifetimeSeconds <= 2) { + this.logger.error( + "Auth token does not live long enough, cannot refetch the token" + ); + return; + } + let tokenValiditySeconds; + if (clientClockSkewMs !== void 0) { + const estimatedServerNowSeconds = (Date.now() - clientClockSkewMs) / 1e3; + tokenValiditySeconds = exp - estimatedServerNowSeconds; + if (tokenValiditySeconds <= 0) { + tokenValiditySeconds = 0; + } + } else { + tokenValiditySeconds = fullLifetimeSeconds; + } + let delay = Math.min( + MAXIMUM_REFRESH_DELAY, + (tokenValiditySeconds - this.refreshTokenLeewaySeconds) * 1e3 + ); + if (delay <= 0) { + this.logger.warn( + `Refetching auth token immediately, configured leeway ${this.refreshTokenLeewaySeconds}s is larger than the token's lifetime ${tokenValiditySeconds}s` + ); + delay = 0; + } + const refetchTokenTimeoutId = setTimeout(() => { + this._logVerbose("running scheduled token refetch"); + void this.refetchToken(); + }, delay); + this.setAuthState({ + state: "waitingForScheduledRefetch", + refetchTokenTimeoutId, + config: this.authState.config + }); + this._logVerbose( + `scheduled preemptive auth token refetching in ${delay}ms` + ); + } + // Protects against simultaneous calls to `setConfig` + // while we're fetching a token + async fetchTokenAndGuardAgainstRace(fetchToken, fetchArgs) { + const originalConfigVersion = ++this.configVersion; + this._logVerbose( + `fetching token with config version ${originalConfigVersion}` + ); + const token = await fetchToken(fetchArgs); + if (this.configVersion !== originalConfigVersion) { + this._logVerbose( + `stale config version, expected ${originalConfigVersion}, got ${this.configVersion}` + ); + return { isFromOutdatedConfig: true }; + } + return { isFromOutdatedConfig: false, value: token }; + } + stop() { + this.resetAuthState(); + this.configVersion++; + this._logVerbose(`config version bumped to ${this.configVersion}`); + } + setAndReportAuthFailed(onAuthChange) { + onAuthChange(false); + this.resetAuthState(); + } + // The sole path to `state === "noAuth"`; consumers rely on this firing + // `notifyRefreshChange(false)` to pair any in-flight `(true)`. May run + // when refresh state is already false. + resetAuthState() { + this.notifyRefreshChange(false); + this.setAuthState({ state: "noAuth" }); + } + setAuthState(newAuth) { + const authStateForLog = newAuth.state === "waitingForServerConfirmationOfFreshToken" ? { + hadAuth: newAuth.hadAuth, + state: newAuth.state, + token: `...${newAuth.token.slice(-7)}` + } : { state: newAuth.state }; + this._logVerbose( + `setting auth state to ${JSON.stringify(authStateForLog)}` + ); + switch (newAuth.state) { + case "waitingForScheduledRefetch": + case "notRefetching": + case "noAuth": + this.tokenConfirmationAttempts = 0; + break; + case "waitingForServerConfirmationOfFreshToken": + case "waitingForServerConfirmationOfCachedToken": + case "initialRefetch": + break; + default: { + newAuth; + } + } + if (this.authState.state === "waitingForScheduledRefetch") { + clearTimeout(this.authState.refetchTokenTimeoutId); + } + this.authState = newAuth; + } + decodeToken(token) { + try { + return jwtDecode(token); + } catch (e) { + this._logVerbose( + `Error decoding token: ${e instanceof Error ? e.message : "Unknown error"}` + ); + return null; + } + } + _logVerbose(message) { + this.logger.logVerbose(`${message} [v${this.configVersion}]`); + } + }; + + // src/browser/sync/metrics.ts + var markNames = [ + "convexClientConstructed", + "convexWebSocketOpen", + "convexFirstMessageReceived" + ]; + function mark(name, sessionId) { + const detail = { sessionId }; + if (typeof performance === "undefined" || !performance.mark) return; + performance.mark(name, { detail }); + } + function performanceMarkToJson(mark2) { + let name = mark2.name.slice("convex".length); + name = name.charAt(0).toLowerCase() + name.slice(1); + return { + name, + startTime: mark2.startTime + }; + } + function getMarksReport(sessionId) { + if (typeof performance === "undefined" || !performance.getEntriesByName) { + return []; + } + const allMarks = []; + for (const name of markNames) { + const marks = performance.getEntriesByName(name).filter((entry) => entry.entryType === "mark").filter((mark2) => mark2.detail.sessionId === sessionId); + allMarks.push(...marks); + } + return allMarks.map(performanceMarkToJson); + } + + // src/browser/sync/client.ts + var BaseConvexClient = class { + address; + state; + requestManager; + webSocketManager; + authenticationManager; + remoteQuerySet; + optimisticQueryResults; + _transitionHandlerCounter = 0; + _nextRequestId; + _onTransitionFns = /* @__PURE__ */ new Map(); + _sessionId; + firstMessageReceived = false; + debug; + logger; + maxObservedTimestamp; + connectionStateSubscribers = /* @__PURE__ */ new Map(); + nextConnectionStateSubscriberId = 0; + _lastPublishedConnectionState; + /** + * @param address - The url of your Convex deployment, often provided + * by an environment variable. E.g. `https://small-mouse-123.convex.cloud`. + * @param onTransition - A callback receiving an array of query tokens + * corresponding to query results that have changed -- additional handlers + * can be added via `addOnTransitionHandler`. + * @param options - See {@link BaseConvexClientOptions} for a full description. + */ + constructor(address, onTransition, options) { + if (typeof address === "object") { + throw new Error( + "Passing a ClientConfig object is no longer supported. Pass the URL of the Convex deployment as a string directly." + ); + } + if (options?.skipConvexDeploymentUrlCheck !== true) { + validateDeploymentUrl(address); + } + options = { ...options }; + const authRefreshTokenLeewaySeconds = options.authRefreshTokenLeewaySeconds ?? 10; + let webSocketConstructor = options.webSocketConstructor; + if (!webSocketConstructor && typeof WebSocket === "undefined") { + throw new Error( + "No WebSocket global variable defined! To use Convex in an environment without WebSocket try the HTTP client: https://docs.convex.dev/api/classes/browser.ConvexHttpClient" + ); + } + webSocketConstructor = webSocketConstructor || WebSocket; + this.debug = options.reportDebugInfoToConvex ?? false; + this.address = address; + this.logger = options.logger === false ? instantiateNoopLogger({ verbose: options.verbose ?? false }) : options.logger !== true && options.logger ? options.logger : instantiateDefaultLogger({ verbose: options.verbose ?? false }); + const i = address.search("://"); + if (i === -1) { + throw new Error("Provided address was not an absolute URL."); + } + const origin = address.substring(i + 3); + const protocol = address.substring(0, i); + let wsProtocol; + if (protocol === "http") { + wsProtocol = "ws"; + } else if (protocol === "https") { + wsProtocol = "wss"; + } else { + throw new Error(`Unknown parent protocol ${protocol}`); + } + const wsUri = `${wsProtocol}://${origin}/api/${version}/sync`; + this.state = new LocalSyncState(); + this.remoteQuerySet = new RemoteQuerySet( + (queryId) => this.state.queryPath(queryId), + this.logger + ); + this.requestManager = new RequestManager( + this.logger, + this.markConnectionStateDirty + ); + const pauseSocket = () => { + this.webSocketManager.pause(); + this.state.pause(); + }; + this.authenticationManager = new AuthenticationManager( + this.state, + { + authenticate: (token) => { + const message = this.state.setAuth(token); + this.webSocketManager.sendMessage(message); + return message.baseVersion; + }, + stopSocket: () => this.webSocketManager.stop(), + tryRestartSocket: () => this.webSocketManager.tryRestart(), + pauseSocket, + resumeSocket: () => this.webSocketManager.resume(), + clearAuth: () => { + this.clearAuth(); + } + }, + { + logger: this.logger, + refreshTokenLeewaySeconds: authRefreshTokenLeewaySeconds, + initialAuthTokenReuse: options.initialAuthTokenReuse ?? false + } + ); + this.optimisticQueryResults = new OptimisticQueryResults(); + this.addOnTransitionHandler((transition) => { + onTransition(transition.queries.map((q) => q.token)); + }); + this._nextRequestId = 0; + this._sessionId = newSessionId(); + const { unsavedChangesWarning } = options; + if (typeof window === "undefined" || typeof window.addEventListener === "undefined") { + if (unsavedChangesWarning === true) { + throw new Error( + "unsavedChangesWarning requested, but window.addEventListener not found! Remove {unsavedChangesWarning: true} from Convex client options." + ); + } + } else if (unsavedChangesWarning !== false) { + window.addEventListener("beforeunload", (e) => { + if (this.requestManager.hasIncompleteRequests()) { + e.preventDefault(); + const confirmationMessage = "Are you sure you want to leave? Your changes may not be saved."; + (e || window.event).returnValue = confirmationMessage; + return confirmationMessage; + } + }); + } + this.webSocketManager = new WebSocketManager( + wsUri, + { + onOpen: (reconnectMetadata) => { + this.mark("convexWebSocketOpen"); + this.webSocketManager.sendMessage({ + ...reconnectMetadata, + type: "Connect", + sessionId: this._sessionId, + maxObservedTimestamp: this.maxObservedTimestamp + }); + this.remoteQuerySet = new RemoteQuerySet( + (queryId) => this.state.queryPath(queryId), + this.logger + ); + const [querySetModification, authModification] = this.state.restart(); + if (authModification) { + this.webSocketManager.sendMessage(authModification); + } + this.webSocketManager.sendMessage(querySetModification); + for (const message of this.requestManager.restart()) { + this.webSocketManager.sendMessage(message); + } + }, + onResume: () => { + const [querySetModification, authModification] = this.state.resume(); + if (authModification) { + this.webSocketManager.sendMessage(authModification); + } + if (querySetModification) { + this.webSocketManager.sendMessage(querySetModification); + } + for (const message of this.requestManager.resume()) { + this.webSocketManager.sendMessage(message); + } + }, + onMessage: (serverMessage) => { + if (!this.firstMessageReceived) { + this.firstMessageReceived = true; + this.mark("convexFirstMessageReceived"); + this.reportMarks(); + } + switch (serverMessage.type) { + case "Transition": { + this.observedTimestamp(serverMessage.endVersion.ts); + this.authenticationManager.onTransition(serverMessage); + this.remoteQuerySet.transition(serverMessage); + this.state.transition(serverMessage); + const completedRequests = this.requestManager.removeCompleted( + this.remoteQuerySet.timestamp() + ); + this.notifyOnQueryResultChanges(completedRequests); + break; + } + case "MutationResponse": { + if (serverMessage.success) { + this.observedTimestamp(serverMessage.ts); + } + const completedMutationInfo = this.requestManager.onResponse(serverMessage); + if (completedMutationInfo !== null) { + this.notifyOnQueryResultChanges( + /* @__PURE__ */ new Map([ + [ + completedMutationInfo.requestId, + completedMutationInfo.result + ] + ]) + ); + } + break; + } + case "ActionResponse": { + this.requestManager.onResponse(serverMessage); + break; + } + case "AuthError": { + this.authenticationManager.onAuthError(serverMessage); + break; + } + case "FatalError": { + const error = logFatalError(this.logger, serverMessage.error); + void this.webSocketManager.terminate(); + throw error; + } + default: { + serverMessage; + } + } + return { + hasSyncedPastLastReconnect: this.hasSyncedPastLastReconnect() + }; + }, + onServerDisconnectError: options.onServerDisconnectError + }, + webSocketConstructor, + this.logger, + this.markConnectionStateDirty, + this.debug + ); + this.mark("convexClientConstructed"); + if (options.expectAuth) { + pauseSocket(); + } + } + /** + * Return true if there is outstanding work from prior to the time of the most recent restart. + * This indicates that the client has not proven itself to have gotten past the issue that + * potentially led to the restart. Use this to influence when to reset backoff after a failure. + */ + hasSyncedPastLastReconnect() { + const hasSyncedPastLastReconnect = this.requestManager.hasSyncedPastLastReconnect() && this.state.hasSyncedPastLastReconnect(); + return hasSyncedPastLastReconnect; + } + observedTimestamp(observedTs) { + if (this.maxObservedTimestamp === void 0 || this.maxObservedTimestamp.lessThanOrEqual(observedTs)) { + this.maxObservedTimestamp = observedTs; + } + } + getMaxObservedTimestamp() { + return this.maxObservedTimestamp; + } + /** + * Compute the current query results based on the remoteQuerySet and the + * current optimistic updates and call `onTransition` for all the changed + * queries. + * + * @param completedMutations - A set of mutation IDs whose optimistic updates + * are no longer needed. + */ + notifyOnQueryResultChanges(completedRequests) { + const remoteQueryResults = this.remoteQuerySet.remoteQueryResults(); + const queryTokenToValue = /* @__PURE__ */ new Map(); + for (const [queryId, result] of remoteQueryResults) { + const queryToken = this.state.queryToken(queryId); + if (queryToken !== null) { + const query = { + result, + udfPath: this.state.queryPath(queryId), + args: this.state.queryArgs(queryId) + }; + queryTokenToValue.set(queryToken, query); + } + } + const changedQueryTokens = this.optimisticQueryResults.ingestQueryResultsFromServer( + queryTokenToValue, + new Set(completedRequests.keys()) + ); + this.handleTransition({ + queries: changedQueryTokens.map((token) => { + const optimisticResult = this.optimisticQueryResults.rawQueryResult(token); + return { + token, + modification: { + kind: "Updated", + result: optimisticResult + } + }; + }), + reflectedMutations: Array.from(completedRequests).map( + ([requestId, result]) => ({ + requestId, + result + }) + ), + timestamp: this.remoteQuerySet.timestamp() + }); + } + handleTransition(transition) { + for (const fn of this._onTransitionFns.values()) { + fn(transition); + } + } + /** + * Add a handler that will be called on a transition. + * + * Any external side effects (e.g. setting React state) should be handled here. + * + * @param fn + * + * @returns + */ + addOnTransitionHandler(fn) { + const id = this._transitionHandlerCounter++; + this._onTransitionFns.set(id, fn); + return () => this._onTransitionFns.delete(id); + } + /** + * Get the current JWT auth token and decoded claims. + */ + getCurrentAuthClaims() { + const authToken = this.state.getAuth(); + let decoded = {}; + if (authToken && authToken.tokenType === "User") { + try { + decoded = authToken ? jwtDecode(authToken.value) : {}; + } catch { + decoded = {}; + } + } else { + return void 0; + } + return { token: authToken.value, decoded }; + } + /** + * Set the authentication token to be used for subsequent queries and mutations. + * `fetchToken` will be called automatically again if a token expires. + * `fetchToken` should return `null` if the token cannot be retrieved, for example + * when the user's rights were permanently revoked. + * @param fetchToken - an async function returning the JWT-encoded OpenID Connect Identity Token + * @param onChange - a callback that will be called when the authentication status changes + * @param onRefreshChange - a callback called with `true` when the socket is paused to fetch a replacement token after a server rejection, and `false` when refresh completes + */ + setAuth(fetchToken, onChange, onRefreshChange) { + void this.authenticationManager.setConfig( + fetchToken, + onChange, + onRefreshChange + ); + } + hasAuth() { + return this.state.hasAuth(); + } + /** @internal */ + setAdminAuth(value, fakeUserIdentity) { + const message = this.state.setAdminAuth(value, fakeUserIdentity); + this.webSocketManager.sendMessage(message); + } + clearAuth() { + const message = this.state.clearAuth(); + this.webSocketManager.sendMessage(message); + } + /** + * Subscribe to a query function. + * + * Whenever this query's result changes, the `onTransition` callback + * passed into the constructor will be called. + * + * @param name - The name of the query. + * @param args - An arguments object for the query. If this is omitted, the + * arguments will be `{}`. + * @param options - A {@link SubscribeOptions} options object for this query. + + * @returns An object containing a {@link QueryToken} corresponding to this + * query and an `unsubscribe` callback. + */ + subscribe(name, args, options) { + const argsObject = parseArgs(args); + const { modification, queryToken, unsubscribe } = this.state.subscribe( + name, + argsObject, + options?.journal, + options?.componentPath + ); + if (modification !== null) { + this.webSocketManager.sendMessage(modification); + } + return { + queryToken, + unsubscribe: () => { + const modification2 = unsubscribe(); + if (modification2) { + this.webSocketManager.sendMessage(modification2); + } + } + }; + } + /** + * A query result based only on the current, local state. + * + * The only way this will return a value is if we're already subscribed to the + * query or its value has been set optimistically. + */ + localQueryResult(udfPath, args) { + const argsObject = parseArgs(args); + const queryToken = serializePathAndArgs(udfPath, argsObject); + return this.optimisticQueryResults.queryResult(queryToken); + } + /** + * Get query result by query token based on current, local state + * + * The only way this will return a value is if we're already subscribed to the + * query or its value has been set optimistically. + * + * @internal + */ + localQueryResultByToken(queryToken) { + return this.optimisticQueryResults.queryResult(queryToken); + } + /** + * Whether local query result is available for a token. + * + * This method does not throw if the result is an error. + * + * @internal + */ + hasLocalQueryResultByToken(queryToken) { + return this.optimisticQueryResults.hasQueryResult(queryToken); + } + /** + * @internal + */ + localQueryLogs(udfPath, args) { + const argsObject = parseArgs(args); + const queryToken = serializePathAndArgs(udfPath, argsObject); + return this.optimisticQueryResults.queryLogs(queryToken); + } + /** + * Retrieve the current {@link QueryJournal} for this query function. + * + * If we have not yet received a result for this query, this will be `undefined`. + * + * @param name - The name of the query. + * @param args - The arguments object for this query. + * @returns The query's {@link QueryJournal} or `undefined`. + */ + queryJournal(name, args) { + const argsObject = parseArgs(args); + const queryToken = serializePathAndArgs(name, argsObject); + return this.state.queryJournal(queryToken); + } + /** + * Get the current {@link ConnectionState} between the client and the Convex + * backend. + * + * @returns The {@link ConnectionState} with the Convex backend. + */ + connectionState() { + const wsConnectionState = this.webSocketManager.connectionState(); + return { + hasInflightRequests: this.requestManager.hasInflightRequests(), + isWebSocketConnected: wsConnectionState.isConnected, + hasEverConnected: wsConnectionState.hasEverConnected, + connectionCount: wsConnectionState.connectionCount, + connectionRetries: wsConnectionState.connectionRetries, + timeOfOldestInflightRequest: this.requestManager.timeOfOldestInflightRequest(), + inflightMutations: this.requestManager.inflightMutations(), + inflightActions: this.requestManager.inflightActions() + }; + } + /** + * Call this whenever the connection state may have changed in a way that could + * require publishing it. Schedules a possibly update. + */ + markConnectionStateDirty = () => { + void Promise.resolve().then(() => { + const curConnectionState = this.connectionState(); + if (JSON.stringify(curConnectionState) !== JSON.stringify(this._lastPublishedConnectionState)) { + this._lastPublishedConnectionState = curConnectionState; + for (const cb of this.connectionStateSubscribers.values()) { + cb(curConnectionState); + } + } + }); + }; + /** + * Subscribe to the {@link ConnectionState} between the client and the Convex + * backend, calling a callback each time it changes. + * + * Subscribed callbacks will be called when any part of ConnectionState changes. + * ConnectionState may grow in future versions (e.g. to provide a array of + * inflight requests) in which case callbacks would be called more frequently. + * + * @returns An unsubscribe function to stop listening. + */ + subscribeToConnectionState(cb) { + const id = this.nextConnectionStateSubscriberId++; + this.connectionStateSubscribers.set(id, cb); + return () => { + this.connectionStateSubscribers.delete(id); + }; + } + /** + * Execute a mutation function. + * + * @param name - The name of the mutation. + * @param args - An arguments object for the mutation. If this is omitted, + * the arguments will be `{}`. + * @param options - A {@link MutationOptions} options object for this mutation. + + * @returns - A promise of the mutation's result. + */ + async mutation(name, args, options) { + const result = await this.mutationInternal(name, args, options); + if (!result.success) { + if (result.errorData !== void 0) { + throw forwardData( + result, + new ConvexError( + createHybridErrorStacktrace("mutation", name, result) + ) + ); + } + throw new Error(createHybridErrorStacktrace("mutation", name, result)); + } + return result.value; + } + /** + * @internal + */ + async mutationInternal(udfPath, args, options, componentPath) { + const { mutationPromise } = this.enqueueMutation( + udfPath, + args, + options, + componentPath + ); + return mutationPromise; + } + /** + * @internal + */ + enqueueMutation(udfPath, args, options, componentPath) { + const mutationArgs = parseArgs(args); + this.tryReportLongDisconnect(); + const requestId = this.nextRequestId; + this._nextRequestId++; + if (options !== void 0) { + const optimisticUpdate = options.optimisticUpdate; + if (optimisticUpdate !== void 0) { + const wrappedUpdate = (localQueryStore) => { + const result = optimisticUpdate( + localQueryStore, + mutationArgs + ); + if (result instanceof Promise) { + this.logger.warn( + "Optimistic update handler returned a Promise. Optimistic updates should be synchronous." + ); + } + }; + const changedQueryTokens = this.optimisticQueryResults.applyOptimisticUpdate( + wrappedUpdate, + requestId + ); + const changedQueries = changedQueryTokens.map((token) => { + const localResult = this.localQueryResultByToken(token); + return { + token, + modification: { + kind: "Updated", + result: localResult === void 0 ? void 0 : { + success: true, + value: localResult, + logLines: [] + } + } + }; + }); + this.handleTransition({ + queries: changedQueries, + reflectedMutations: [], + timestamp: this.remoteQuerySet.timestamp() + }); + } + } + const message = { + type: "Mutation", + requestId, + udfPath, + componentPath, + args: [convexToJson(mutationArgs)] + }; + const mightBeSent = this.webSocketManager.sendMessage(message); + const mutationPromise = this.requestManager.request(message, mightBeSent); + return { + requestId, + mutationPromise + }; + } + /** + * Execute an action function. + * + * @param name - The name of the action. + * @param args - An arguments object for the action. If this is omitted, + * the arguments will be `{}`. + * @returns A promise of the action's result. + */ + async action(name, args) { + const result = await this.actionInternal(name, args); + if (!result.success) { + if (result.errorData !== void 0) { + throw forwardData( + result, + new ConvexError(createHybridErrorStacktrace("action", name, result)) + ); + } + throw new Error(createHybridErrorStacktrace("action", name, result)); + } + return result.value; + } + /** + * @internal + */ + async actionInternal(udfPath, args, componentPath) { + const actionArgs = parseArgs(args); + const requestId = this.nextRequestId; + this._nextRequestId++; + this.tryReportLongDisconnect(); + const message = { + type: "Action", + requestId, + udfPath, + componentPath, + args: [convexToJson(actionArgs)] + }; + const mightBeSent = this.webSocketManager.sendMessage(message); + return this.requestManager.request(message, mightBeSent); + } + /** + * Close any network handles associated with this client and stop all subscriptions. + * + * Call this method when you're done with an {@link BaseConvexClient} to + * dispose of its sockets and resources. + * + * @returns A `Promise` fulfilled when the connection has been completely closed. + */ + async close() { + this.authenticationManager.stop(); + return this.webSocketManager.terminate(); + } + /** + * Return the address for this client, useful for creating a new client. + * + * Not guaranteed to match the address with which this client was constructed: + * it may be canonicalized. + */ + get url() { + return this.address; + } + /** + * @internal + */ + get nextRequestId() { + return this._nextRequestId; + } + /** + * @internal + */ + get sessionId() { + return this._sessionId; + } + // Instance property so that `mark()` doesn't need to be called as a method. + mark = (name) => { + if (this.debug) { + mark(name, this.sessionId); + } + }; + /** + * Reports performance marks to the server. This should only be called when + * we have a functional websocket. + */ + reportMarks() { + if (this.debug) { + const report = getMarksReport(this.sessionId); + this.webSocketManager.sendMessage({ + type: "Event", + eventType: "ClientConnect", + event: report + }); + } + } + tryReportLongDisconnect() { + if (!this.debug) { + return; + } + const timeOfOldestRequest = this.connectionState().timeOfOldestInflightRequest; + if (timeOfOldestRequest === null || Date.now() - timeOfOldestRequest.getTime() <= 60 * 1e3) { + return; + } + const endpoint = `${this.address}/api/debug_event`; + fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }, + body: JSON.stringify({ event: "LongWebsocketDisconnect" }) + }).then((response) => { + if (!response.ok) { + this.logger.warn( + "Analytics request failed with response:", + response.body + ); + } + }).catch((error) => { + this.logger.warn("Analytics response failed with error:", error); + }); + } + }; + + // src/browser/sync/pagination.ts + function asPaginationResult(value) { + if (typeof value !== "object" || value === null || !Array.isArray(value.page) || typeof value.isDone !== "boolean" || typeof value.continueCursor !== "string") { + throw new Error(`Not a valid paginated query result: ${value?.toString()}`); + } + return value; + } + + // src/browser/sync/paginated_query_client.ts + var PaginatedQueryClient = class { + constructor(client, onTransition) { + this.client = client; + this.onTransition = onTransition; + this.lastTransitionTs = Long.fromNumber(0); + this.client.addOnTransitionHandler( + (transition) => this.onBaseTransition(transition) + ); + } + paginatedQuerySet = /* @__PURE__ */ new Map(); + // hold onto a real Transition so we can construct synthetic ones with that timestamp + lastTransitionTs; + /** + * Subscribe to a paginated query. + * + * @param name - The name of the paginated query function + * @param args - Arguments for the query (excluding paginationOpts) + * @param options - Pagination options including initialNumItems + * @returns Object with paginatedQueryToken and unsubscribe function + */ + subscribe(name, args, options) { + const canonicalizedUdfPath = canonicalizeUdfPath(name); + const token = serializePaginatedPathAndArgs( + canonicalizedUdfPath, + args, + options + ); + const unsubscribe = () => this.removePaginatedQuerySubscriber(token); + const existingEntry = this.paginatedQuerySet.get(token); + if (existingEntry) { + existingEntry.numSubscribers += 1; + return { + paginatedQueryToken: token, + unsubscribe + }; + } + this.paginatedQuerySet.set(token, { + token, + canonicalizedUdfPath, + args, + numSubscribers: 1, + options: { initialNumItems: options.initialNumItems }, + nextPageKey: 0, + pageKeys: [], + pageKeyToQuery: /* @__PURE__ */ new Map(), + ongoingSplits: /* @__PURE__ */ new Map(), + skip: false, + id: options.id + }); + this.addPageToPaginatedQuery(token, null, options.initialNumItems); + return { + paginatedQueryToken: token, + unsubscribe + }; + } + /** + * Get current results for a paginated query based on local state. + * + * Throws an error when one of the pages has errored. + */ + localQueryResult(name, args, options) { + const canonicalizedUdfPath = canonicalizeUdfPath(name); + const token = serializePaginatedPathAndArgs( + canonicalizedUdfPath, + args, + options + ); + return this.localQueryResultByToken(token); + } + /** + * @internal + */ + localQueryResultByToken(token) { + const paginatedQuery = this.paginatedQuerySet.get(token); + if (!paginatedQuery) { + return void 0; + } + const activePages = this.activePageQueryTokens(paginatedQuery); + if (activePages.length === 0) { + return { + results: [], + status: "LoadingFirstPage", + loadMore: (numItems) => { + return this.loadMoreOfPaginatedQuery(token, numItems); + } + }; + } + let allResults = []; + let hasUndefined = false; + let isDone = false; + for (const pageToken of activePages) { + const result = this.client.localQueryResultByToken(pageToken); + if (result === void 0) { + hasUndefined = true; + isDone = false; + continue; + } + const paginationResult = asPaginationResult(result); + allResults = allResults.concat(paginationResult.page); + isDone = !!paginationResult.isDone; + } + let status; + if (hasUndefined) { + status = allResults.length === 0 ? "LoadingFirstPage" : "LoadingMore"; + } else if (isDone) { + status = "Exhausted"; + } else { + status = "CanLoadMore"; + } + return { + results: allResults, + status, + loadMore: (numItems) => { + return this.loadMoreOfPaginatedQuery(token, numItems); + } + }; + } + onBaseTransition(transition) { + const changedBaseTokens = transition.queries.map((q) => q.token); + const changed = this.queriesContainingTokens(changedBaseTokens); + let paginatedQueries = []; + if (changed.length > 0) { + this.processPaginatedQuerySplits( + changed, + (token) => this.client.localQueryResultByToken(token) + ); + paginatedQueries = changed.map((token) => ({ + token, + modification: { + kind: "Updated", + result: this.localQueryResultByToken(token) + } + })); + } + const extendedTransition = { + ...transition, + paginatedQueries + }; + this.onTransition(extendedTransition); + } + /** + * Load more items for a paginated query. + * + * This *always* causes a transition, the status of the query + * has probably changed from "CanLoadMore" to "LoadingMore". + * Data might have changed too: maybe a subscription to this page + * query already exists (unlikely but possible) or this page query + * has an optimistic update providing some initial data. + * + * @internal + */ + loadMoreOfPaginatedQuery(token, numItems) { + this.mustGetPaginatedQuery(token); + const lastPageToken = this.queryTokenForLastPageOfPaginatedQuery(token); + const lastPageResult = this.client.localQueryResultByToken(lastPageToken); + if (!lastPageResult) { + return false; + } + const paginationResult = asPaginationResult(lastPageResult); + if (paginationResult.isDone) { + return false; + } + this.addPageToPaginatedQuery( + token, + paginationResult.continueCursor, + numItems + ); + const loadMoreTransition = { + timestamp: this.lastTransitionTs, + reflectedMutations: [], + queries: [], + paginatedQueries: [ + { + token, + modification: { + kind: "Updated", + result: this.localQueryResultByToken(token) + } + } + ] + }; + this.onTransition(loadMoreTransition); + return true; + } + /** + * @internal + */ + queriesContainingTokens(queryTokens) { + if (queryTokens.length === 0) { + return []; + } + const changed = []; + const queryTokenSet = new Set(queryTokens); + for (const [paginatedToken, paginatedQuery] of this.paginatedQuerySet) { + for (const pageToken of this.allQueryTokens(paginatedQuery)) { + if (queryTokenSet.has(pageToken)) { + changed.push(paginatedToken); + break; + } + } + } + return changed; + } + /** + * @internal + */ + processPaginatedQuerySplits(changed, getResult) { + for (const paginatedQueryToken of changed) { + const paginatedQuery = this.mustGetPaginatedQuery(paginatedQueryToken); + const { ongoingSplits, pageKeyToQuery, pageKeys } = paginatedQuery; + for (const [pageKey, [splitKey1, splitKey2]] of ongoingSplits) { + const bothNewPagesLoaded = getResult(pageKeyToQuery.get(splitKey1).queryToken) !== void 0 && getResult(pageKeyToQuery.get(splitKey2).queryToken) !== void 0; + if (bothNewPagesLoaded) { + this.completePaginatedQuerySplit( + paginatedQuery, + pageKey, + splitKey1, + splitKey2 + ); + } + } + for (const pageKey of pageKeys) { + if (ongoingSplits.has(pageKey)) { + continue; + } + const pageEntry = pageKeyToQuery.get(pageKey); + if (!pageEntry) { + throw new Error(`No page query for active pageKey ${pageKey}`); + } + const pageResult = getResult(pageEntry.queryToken); + if (!pageResult) { + continue; + } + const result = asPaginationResult(pageResult); + const shouldSplit = result.splitCursor && (result.pageStatus === "SplitRecommended" || result.pageStatus === "SplitRequired" || // This client-driven page splitting condition will change in the future. + result.page.length > paginatedQuery.options.initialNumItems * 2); + if (shouldSplit) { + this.splitPaginatedQueryPage( + paginatedQuery, + pageKey, + pageEntry.cursor, + result.splitCursor, + // we just checked + result.continueCursor + ); + } + } + } + } + splitPaginatedQueryPage(paginatedQuery, pageKey, startCursor, splitCursor, continueCursor) { + const splitKey1 = paginatedQuery.nextPageKey++; + const splitKey2 = paginatedQuery.nextPageKey++; + const paginationOpts = { + numItems: paginatedQuery.options.initialNumItems, + id: paginatedQuery.id + }; + const firstSubscription = this.client.subscribe( + paginatedQuery.canonicalizedUdfPath, + { + ...paginatedQuery.args, + paginationOpts: { + ...paginationOpts, + cursor: startCursor, + endCursor: splitCursor + } + } + ); + paginatedQuery.pageKeyToQuery.set(splitKey1, { + ...firstSubscription, + cursor: startCursor + }); + const secondSubscription = this.client.subscribe( + paginatedQuery.canonicalizedUdfPath, + { + ...paginatedQuery.args, + paginationOpts: { + ...paginationOpts, + cursor: splitCursor, + endCursor: continueCursor + } + } + ); + paginatedQuery.pageKeyToQuery.set(splitKey2, { + ...secondSubscription, + cursor: splitCursor + }); + paginatedQuery.ongoingSplits.set(pageKey, [splitKey1, splitKey2]); + } + /** + * @internal + */ + addPageToPaginatedQuery(token, continueCursor, numItems) { + const paginatedQuery = this.mustGetPaginatedQuery(token); + const pageKey = paginatedQuery.nextPageKey++; + const paginationOpts = { + cursor: continueCursor, + numItems, + id: paginatedQuery.id + }; + const pageArgs = { + ...paginatedQuery.args, + paginationOpts + }; + const subscription = this.client.subscribe( + paginatedQuery.canonicalizedUdfPath, + pageArgs + ); + paginatedQuery.pageKeys.push(pageKey); + paginatedQuery.pageKeyToQuery.set(pageKey, { + ...subscription, + cursor: continueCursor + }); + return subscription; + } + removePaginatedQuerySubscriber(token) { + const paginatedQuery = this.paginatedQuerySet.get(token); + if (!paginatedQuery) { + return; + } + paginatedQuery.numSubscribers -= 1; + if (paginatedQuery.numSubscribers > 0) { + return; + } + for (const subscription of paginatedQuery.pageKeyToQuery.values()) { + subscription.unsubscribe(); + } + this.paginatedQuerySet.delete(token); + } + completePaginatedQuerySplit(paginatedQuery, pageKey, splitKey1, splitKey2) { + const originalQuery = paginatedQuery.pageKeyToQuery.get(pageKey); + paginatedQuery.pageKeyToQuery.delete(pageKey); + const pageIndex = paginatedQuery.pageKeys.indexOf(pageKey); + paginatedQuery.pageKeys.splice(pageIndex, 1, splitKey1, splitKey2); + paginatedQuery.ongoingSplits.delete(pageKey); + originalQuery.unsubscribe(); + } + /** The query tokens for all active pages, in result order */ + activePageQueryTokens(paginatedQuery) { + return paginatedQuery.pageKeys.map( + (pageKey) => paginatedQuery.pageKeyToQuery.get(pageKey).queryToken + ); + } + allQueryTokens(paginatedQuery) { + return Array.from(paginatedQuery.pageKeyToQuery.values()).map( + (sub) => sub.queryToken + ); + } + queryTokenForLastPageOfPaginatedQuery(token) { + const paginatedQuery = this.mustGetPaginatedQuery(token); + const lastPageKey = paginatedQuery.pageKeys[paginatedQuery.pageKeys.length - 1]; + if (lastPageKey === void 0) { + throw new Error(`No pages for paginated query ${token}`); + } + return paginatedQuery.pageKeyToQuery.get(lastPageKey).queryToken; + } + mustGetPaginatedQuery(token) { + const paginatedQuery = this.paginatedQuerySet.get(token); + if (!paginatedQuery) { + throw new Error("paginated query no longer exists for token " + token); + } + return paginatedQuery; + } + }; + + // src/browser/simple_client.ts + var defaultWebSocketConstructor; + var ConvexClient = class { + listeners; + _client; + _paginatedClient; + // A synthetic server event to run callbacks the first time + callNewListenersWithCurrentValuesTimer; + _closed; + _disabled; + /** + * Once closed no registered callbacks will fire again. + */ + get closed() { + return this._closed; + } + get client() { + if (this._client) return this._client; + throw new Error("ConvexClient is disabled"); + } + /** + * @internal + */ + get paginatedClient() { + if (this._paginatedClient) return this._paginatedClient; + throw new Error("ConvexClient is disabled"); + } + get disabled() { + return this._disabled; + } + /** + * Construct a client and immediately initiate a WebSocket connection to the passed address. + * + * @public + */ + constructor(address, options = {}) { + if (options.skipConvexDeploymentUrlCheck !== true) { + validateDeploymentUrl(address); + } + const { disabled, ...baseOptions } = options; + this._closed = false; + this._disabled = !!disabled; + if (defaultWebSocketConstructor && !("webSocketConstructor" in baseOptions) && typeof WebSocket === "undefined") { + baseOptions.webSocketConstructor = defaultWebSocketConstructor; + } + if (typeof window === "undefined" && !("unsavedChangesWarning" in baseOptions)) { + baseOptions.unsavedChangesWarning = false; + } + if (!this.disabled) { + this._client = new BaseConvexClient( + address, + () => { + }, + // NOP, let the paginated query client do it all + baseOptions + ); + this._paginatedClient = new PaginatedQueryClient( + this._client, + (transition) => this._transition(transition) + ); + } + this.listeners = /* @__PURE__ */ new Set(); + } + /** + * Call a callback whenever a new result for a query is received. The callback + * will run soon after being registered if a result for the query is already + * in memory. + * + * The return value is an {@link Unsubscribe} object which is both a function + * an an object with properties. Both of the patterns below work with this object: + * + *```ts + * // call the return value as a function + * const unsubscribe = client.onUpdate(api.messages.list, {}, (messages) => { + * console.log(messages); + * }); + * unsubscribe(); + * + * // unpack the return value into its properties + * const { + * getCurrentValue, + * unsubscribe, + * } = client.onUpdate(api.messages.list, {}, (messages) => { + * console.log(messages); + * }); + *``` + * + * @param query - A {@link server.FunctionReference} for the public query to run. + * @param args - The arguments to run the query with. + * @param callback - Function to call when the query result updates. + * @param onError - Function to call when the query result updates with an error. + * If not provided, errors will be thrown instead of calling the callback. + * + * @return an {@link Unsubscribe} function to stop calling the onUpdate function. + */ + onUpdate(query, args, callback, onError) { + if (this.disabled) { + return this.createDisabledUnsubscribe(); + } + const { queryToken, unsubscribe } = this.client.subscribe( + getFunctionName(query), + args + ); + const queryInfo = { + queryToken, + callback, + onError, + unsubscribe, + hasEverRun: false, + query, + args, + paginationOptions: void 0 + }; + this.listeners.add(queryInfo); + if (this.queryResultReady(queryToken) && this.callNewListenersWithCurrentValuesTimer === void 0) { + this.callNewListenersWithCurrentValuesTimer = setTimeout( + () => this.callNewListenersWithCurrentValues(), + 0 + ); + } + const unsubscribeProps = { + unsubscribe: () => { + if (this.closed) { + return; + } + this.listeners.delete(queryInfo); + unsubscribe(); + }, + getCurrentValue: () => this.client.localQueryResultByToken(queryToken), + getQueryLogs: () => this.client.localQueryLogs(queryToken) + }; + const ret = unsubscribeProps.unsubscribe; + Object.assign(ret, unsubscribeProps); + return ret; + } + /** + * Call a callback whenever a new result for a paginated query is received. + * + * This is an experimental preview: the final API may change. + * In particular, caching behavior, page splitting, and required paginated query options + * may change. + * + * @param query - A {@link server.FunctionReference} for the public query to run. + * @param args - The arguments to run the query with. + * @param options - Options for the paginated query including initialNumItems and id. + * @param callback - Function to call when the query result updates. + * @param onError - Function to call when the query result updates with an error. + * + * @return an {@link Unsubscribe} function to stop calling the callback. + */ + onPaginatedUpdate_experimental(query, args, options, callback, onError) { + if (this.disabled) { + return this.createDisabledUnsubscribe(); + } + const paginationOptions = { + initialNumItems: options.initialNumItems, + id: -1 + }; + const { paginatedQueryToken, unsubscribe } = this.paginatedClient.subscribe( + getFunctionName(query), + args, + // Simple client doesn't use IDs, there's no expectation that these queries remain separate. + paginationOptions + ); + const queryInfo = { + queryToken: paginatedQueryToken, + callback, + onError, + unsubscribe, + hasEverRun: false, + query, + args, + paginationOptions + }; + this.listeners.add(queryInfo); + if (!!this.paginatedClient.localQueryResultByToken(paginatedQueryToken) && this.callNewListenersWithCurrentValuesTimer === void 0) { + this.callNewListenersWithCurrentValuesTimer = setTimeout( + () => this.callNewListenersWithCurrentValues(), + 0 + ); + } + const unsubscribeProps = { + unsubscribe: () => { + if (this.closed) { + return; + } + this.listeners.delete(queryInfo); + unsubscribe(); + }, + getCurrentValue: () => { + const result = this.paginatedClient.localQueryResult( + getFunctionName(query), + args, + paginationOptions + ); + return result; + }, + getQueryLogs: () => [] + // Paginated queries don't aggregate their logs + }; + const ret = unsubscribeProps.unsubscribe; + Object.assign(ret, unsubscribeProps); + return ret; + } + // Run all callbacks that have never been run before if they have a query + // result available now. + callNewListenersWithCurrentValues() { + this.callNewListenersWithCurrentValuesTimer = void 0; + this._transition({ queries: [], paginatedQueries: [] }, true); + } + queryResultReady(queryToken) { + return this.client.hasLocalQueryResultByToken(queryToken); + } + createDisabledUnsubscribe() { + const disabledUnsubscribe = (() => { + }); + const unsubscribeProps = { + unsubscribe: disabledUnsubscribe, + getCurrentValue: () => void 0, + getQueryLogs: () => void 0 + }; + Object.assign(disabledUnsubscribe, unsubscribeProps); + return disabledUnsubscribe; + } + async close() { + if (this.disabled) return; + this.listeners.clear(); + this._closed = true; + if (this._paginatedClient) { + this._paginatedClient = void 0; + } + return this.client.close(); + } + /** + * Get the current JWT auth token and decoded claims. + */ + getAuth() { + if (this.disabled) return; + return this.client.getCurrentAuthClaims(); + } + /** + * Set the authentication token to be used for subsequent queries and mutations. + * `fetchToken` will be called automatically again if a token expires. + * `fetchToken` should return `null` if the token cannot be retrieved, for example + * when the user's rights were permanently revoked. + * @param fetchToken - an async function returning the JWT (typically an OpenID Connect Identity Token) + * @param onChange - a callback that will be called when the authentication status changes + */ + setAuth(fetchToken, onChange) { + if (this.disabled) return; + this.client.setAuth( + fetchToken, + onChange ?? (() => { + }) + ); + } + /** + * @internal + */ + setAdminAuth(token, identity) { + if (this.closed) { + throw new Error("ConvexClient has already been closed."); + } + if (this.disabled) return; + this.client.setAdminAuth(token, identity); + } + /** + * @internal + */ + _transition({ + queries, + paginatedQueries + }, callNewListeners = false) { + const updatedQueries = [ + ...queries.map((q) => q.token), + ...paginatedQueries.map((q) => q.token) + ]; + for (const queryInfo of this.listeners) { + const { callback, queryToken, onError, hasEverRun } = queryInfo; + const isPaginatedQuery = serializedQueryTokenIsPaginated(queryToken); + const hasResultReady = isPaginatedQuery ? !!this.paginatedClient.localQueryResultByToken(queryToken) : this.client.hasLocalQueryResultByToken(queryToken); + if (updatedQueries.includes(queryToken) || callNewListeners && !hasEverRun && hasResultReady) { + queryInfo.hasEverRun = true; + let newValue; + try { + if (isPaginatedQuery) { + newValue = this.paginatedClient.localQueryResultByToken(queryToken); + } else { + newValue = this.client.localQueryResultByToken(queryToken); + } + } catch (error) { + if (!(error instanceof Error)) throw error; + if (onError) { + onError( + error, + "Second argument to onUpdate onError is reserved for later use" + ); + } else { + void Promise.reject(error); + } + continue; + } + callback( + newValue, + "Second argument to onUpdate callback is reserved for later use" + ); + } + } + } + /** + * Execute a mutation function. + * + * @param mutation - A {@link server.FunctionReference} for the public mutation + * to run. + * @param args - An arguments object for the mutation. + * @param options - A {@link MutationOptions} options object for the mutation. + * @returns A promise of the mutation's result. + */ + async mutation(mutation, args, options) { + if (this.disabled) throw new Error("ConvexClient is disabled"); + return await this.client.mutation(getFunctionName(mutation), args, options); + } + /** + * Execute an action function. + * + * @param action - A {@link server.FunctionReference} for the public action + * to run. + * @param args - An arguments object for the action. + * @returns A promise of the action's result. + */ + async action(action, args) { + if (this.disabled) throw new Error("ConvexClient is disabled"); + return await this.client.action(getFunctionName(action), args); + } + /** + * Fetch a query result once. + * + * @param query - A {@link server.FunctionReference} for the public query + * to run. + * @param args - An arguments object for the query. + * @returns A promise of the query's result. + */ + async query(query, args) { + if (this.disabled) throw new Error("ConvexClient is disabled"); + const value = this.client.localQueryResult(getFunctionName(query), args); + if (value !== void 0) return Promise.resolve(value); + return new Promise((resolve, reject) => { + const { unsubscribe } = this.onUpdate( + query, + args, + (value2) => { + unsubscribe(); + resolve(value2); + }, + (e) => { + unsubscribe(); + reject(e); + } + ); + }); + } + /** + * Get the current {@link ConnectionState} between the client and the Convex + * backend. + * + * @returns The {@link ConnectionState} with the Convex backend. + */ + connectionState() { + if (this.disabled) throw new Error("ConvexClient is disabled"); + return this.client.connectionState(); + } + /** + * Subscribe to the {@link ConnectionState} between the client and the Convex + * backend, calling a callback each time it changes. + * + * Subscribed callbacks will be called when any part of ConnectionState changes. + * ConnectionState may grow in future versions (e.g. to provide a array of + * inflight requests) in which case callbacks would be called more frequently. + * + * @returns An unsubscribe function to stop listening. + */ + subscribeToConnectionState(cb) { + if (this.disabled) return () => { + }; + return this.client.subscribeToConnectionState(cb); + } + }; + + // src/browser/http_client.ts + var STATUS_CODE_UDF_FAILED = 560; + var specifiedFetch = void 0; + var ConvexHttpClient = class { + address; + auth; + adminAuth; + encodedTsPromise; + debug; + fetchOptions; + fetch; + logger; + mutationQueue = []; + isProcessingQueue = false; + /** + * Create a new {@link ConvexHttpClient}. + * + * @param address - The url of your Convex deployment, often provided + * by an environment variable. E.g. `https://small-mouse-123.convex.cloud`. + * @param options - An object of options. + * - `skipConvexDeploymentUrlCheck` - Skip validating that the Convex deployment URL looks like + * `https://happy-animal-123.convex.cloud` or localhost. This can be useful if running a self-hosted + * Convex backend that uses a different URL. + * - `logger` - A logger or a boolean. If not provided, logs to the console. + * You can construct your own logger to customize logging to log elsewhere + * or not log at all, or use `false` as a shorthand for a no-op logger. + * A logger is an object with 4 methods: log(), warn(), error(), and logVerbose(). + * These methods can receive multiple arguments of any types, like console.log(). + * - `auth` - A JWT containing identity claims accessible in Convex functions. + * This identity may expire so it may be necessary to call `setAuth()` later, + * but for short-lived clients it's convenient to specify this value here. + * - `fetch` - A custom fetch implementation to use for all HTTP requests made by this client. + */ + constructor(address, options) { + if (typeof options === "boolean") { + throw new Error( + "skipConvexDeploymentUrlCheck as the second argument is no longer supported. Please pass an options object, `{ skipConvexDeploymentUrlCheck: true }`." + ); + } + const opts = options ?? {}; + if (opts.skipConvexDeploymentUrlCheck !== true) { + validateDeploymentUrl(address); + } + this.logger = options?.logger === false ? instantiateNoopLogger({ verbose: false }) : options?.logger !== true && options?.logger ? options.logger : instantiateDefaultLogger({ verbose: false }); + this.address = address; + this.debug = true; + this.auth = void 0; + this.adminAuth = void 0; + this.fetch = options?.fetch; + if (options?.auth) { + this.setAuth(options.auth); + } + } + /** + * Obtain the {@link ConvexHttpClient}'s URL to its backend. + * @deprecated Use url, which returns the url without /api at the end. + * + * @returns The URL to the Convex backend, including the client's API version. + */ + backendUrl() { + return `${this.address}/api`; + } + /** + * Return the address for this client, useful for creating a new client. + * + * Not guaranteed to match the address with which this client was constructed: + * it may be canonicalized. + */ + get url() { + return this.address; + } + /** + * Set the authentication token to be used for subsequent queries and mutations. + * + * Should be called whenever the token changes (i.e. due to expiration and refresh). + * + * @param value - JWT-encoded OpenID Connect identity token. + */ + setAuth(value) { + this.clearAuth(); + this.auth = value; + } + /** + * Set admin auth token to allow calling internal queries, mutations, and actions + * and acting as an identity. + * + * @internal + */ + setAdminAuth(token, actingAsIdentity) { + this.clearAuth(); + if (actingAsIdentity !== void 0) { + const bytes = new TextEncoder().encode(JSON.stringify(actingAsIdentity)); + const actingAsIdentityEncoded = btoa(String.fromCodePoint(...bytes)); + this.adminAuth = `${token}:${actingAsIdentityEncoded}`; + } else { + this.adminAuth = token; + } + } + /** + * Clear the current authentication token if set. + */ + clearAuth() { + this.auth = void 0; + this.adminAuth = void 0; + } + /** + * Sets whether the result log lines should be printed on the console or not. + * + * @internal + */ + setDebug(debug) { + this.debug = debug; + } + /** + * Used to customize the fetch behavior in some runtimes. + * + * @internal + */ + setFetchOptions(fetchOptions) { + this.fetchOptions = fetchOptions; + } + /** + * This API is experimental: it may change or disappear. + * + * Execute a Convex query function at the same timestamp as every other + * consistent query execution run by this HTTP client. + * + * This doesn't make sense for long-lived ConvexHttpClients as Convex + * backends can read a limited amount into the past: beyond 30 seconds + * in the past may not be available. + * + * Create a new client to use a consistent time. + * + * @param name - The name of the query. + * @param args - The arguments object for the query. If this is omitted, + * the arguments will be `{}`. + * @returns A promise of the query's result. + * + * @deprecated This API is experimental: it may change or disappear. + */ + async consistentQuery(query, ...args) { + const queryArgs = parseArgs(args[0]); + const timestampPromise = this.getTimestamp(); + return await this.queryInner(query, queryArgs, { timestampPromise }); + } + async getTimestamp() { + if (this.encodedTsPromise) { + return this.encodedTsPromise; + } + return this.encodedTsPromise = this.getTimestampInner(); + } + async getTimestampInner() { + const localFetch = this.fetch || specifiedFetch || fetch; + const headers = { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }; + const response = await localFetch(`${this.address}/api/query_ts`, { + ...this.fetchOptions, + method: "POST", + headers + }); + if (!response.ok) { + throw new Error(await response.text()); + } + const { ts } = await response.json(); + return ts; + } + /** + * Execute a Convex query function. + * + * @param name - The name of the query. + * @param args - The arguments object for the query. If this is omitted, + * the arguments will be `{}`. + * @returns A promise of the query's result. + */ + async query(query, ...args) { + const queryArgs = parseArgs(args[0]); + return await this.queryInner(query, queryArgs, {}); + } + async queryInner(query, queryArgs, options) { + const name = getFunctionName(query); + const args = [convexToJson(queryArgs)]; + const headers = { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }; + if (this.adminAuth) { + headers["Authorization"] = `Convex ${this.adminAuth}`; + } else if (this.auth) { + headers["Authorization"] = `Bearer ${this.auth}`; + } + const localFetch = this.fetch || specifiedFetch || fetch; + const timestamp = options.timestampPromise ? await options.timestampPromise : void 0; + const body = JSON.stringify({ + path: name, + format: "convex_encoded_json", + args, + ...timestamp ? { ts: timestamp } : {} + }); + const endpoint = timestamp ? `${this.address}/api/query_at_ts` : `${this.address}/api/query`; + const response = await localFetch(endpoint, { + ...this.fetchOptions, + body, + method: "POST", + headers + }); + if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) { + throw new Error(await response.text()); + } + const respJSON = await response.json(); + if (this.debug) { + for (const line of respJSON.logLines ?? []) { + logForFunction(this.logger, "info", "query", name, line); + } + } + switch (respJSON.status) { + case "success": + return jsonToConvex(respJSON.value); + case "error": + if (respJSON.errorData !== void 0) { + throw forwardErrorData( + respJSON.errorData, + new ConvexError(respJSON.errorMessage) + ); + } + throw new Error(respJSON.errorMessage); + default: + throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`); + } + } + async mutationInner(mutation, mutationArgs) { + const name = getFunctionName(mutation); + const body = JSON.stringify({ + path: name, + format: "convex_encoded_json", + args: [convexToJson(mutationArgs)] + }); + const headers = { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }; + if (this.adminAuth) { + headers["Authorization"] = `Convex ${this.adminAuth}`; + } else if (this.auth) { + headers["Authorization"] = `Bearer ${this.auth}`; + } + const localFetch = this.fetch || specifiedFetch || fetch; + const response = await localFetch(`${this.address}/api/mutation`, { + ...this.fetchOptions, + body, + method: "POST", + headers + }); + if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) { + throw new Error(await response.text()); + } + const respJSON = await response.json(); + if (this.debug) { + for (const line of respJSON.logLines ?? []) { + logForFunction(this.logger, "info", "mutation", name, line); + } + } + switch (respJSON.status) { + case "success": + return jsonToConvex(respJSON.value); + case "error": + if (respJSON.errorData !== void 0) { + throw forwardErrorData( + respJSON.errorData, + new ConvexError(respJSON.errorMessage) + ); + } + throw new Error(respJSON.errorMessage); + default: + throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`); + } + } + async processMutationQueue() { + if (this.isProcessingQueue) { + return; + } + this.isProcessingQueue = true; + while (this.mutationQueue.length > 0) { + const { mutation, args, resolve, reject } = this.mutationQueue.shift(); + try { + const result = await this.mutationInner(mutation, args); + resolve(result); + } catch (error) { + reject(error); + } + } + this.isProcessingQueue = false; + } + enqueueMutation(mutation, args) { + return new Promise((resolve, reject) => { + this.mutationQueue.push({ mutation, args, resolve, reject }); + void this.processMutationQueue(); + }); + } + /** + * Execute a Convex mutation function. Mutations are queued by default. + * + * @param name - The name of the mutation. + * @param args - The arguments object for the mutation. If this is omitted, + * the arguments will be `{}`. + * @param options - An optional object containing + * @returns A promise of the mutation's result. + */ + async mutation(mutation, ...args) { + const [fnArgs, options] = args; + const mutationArgs = parseArgs(fnArgs); + const queued = !options?.skipQueue; + if (queued) { + return await this.enqueueMutation(mutation, mutationArgs); + } else { + return await this.mutationInner(mutation, mutationArgs); + } + } + /** + * Execute a Convex action function. Actions are not queued. + * + * @param name - The name of the action. + * @param args - The arguments object for the action. If this is omitted, + * the arguments will be `{}`. + * @returns A promise of the action's result. + */ + async action(action, ...args) { + const actionArgs = parseArgs(args[0]); + const name = getFunctionName(action); + const body = JSON.stringify({ + path: name, + format: "convex_encoded_json", + args: [convexToJson(actionArgs)] + }); + const headers = { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }; + if (this.adminAuth) { + headers["Authorization"] = `Convex ${this.adminAuth}`; + } else if (this.auth) { + headers["Authorization"] = `Bearer ${this.auth}`; + } + const localFetch = this.fetch || specifiedFetch || fetch; + const response = await localFetch(`${this.address}/api/action`, { + ...this.fetchOptions, + body, + method: "POST", + headers + }); + if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) { + throw new Error(await response.text()); + } + const respJSON = await response.json(); + if (this.debug) { + for (const line of respJSON.logLines ?? []) { + logForFunction(this.logger, "info", "action", name, line); + } + } + switch (respJSON.status) { + case "success": + return jsonToConvex(respJSON.value); + case "error": + if (respJSON.errorData !== void 0) { + throw forwardErrorData( + respJSON.errorData, + new ConvexError(respJSON.errorMessage) + ); + } + throw new Error(respJSON.errorMessage); + default: + throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`); + } + } + /** + * Execute a Convex function of an unknown type. These function calls are not queued. + * + * @param name - The name of the function. + * @param args - The arguments object for the function. If this is omitted, + * the arguments will be `{}`. + * @returns A promise of the function's result. + * + * @internal + */ + async function(anyFunction, componentPath, ...args) { + const functionArgs = parseArgs(args[0]); + const name = typeof anyFunction === "string" ? anyFunction : getFunctionName(anyFunction); + const body = JSON.stringify({ + componentPath, + path: name, + format: "convex_encoded_json", + args: convexToJson(functionArgs) + }); + const headers = { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }; + if (this.adminAuth) { + headers["Authorization"] = `Convex ${this.adminAuth}`; + } else if (this.auth) { + headers["Authorization"] = `Bearer ${this.auth}`; + } + const localFetch = this.fetch || specifiedFetch || fetch; + const response = await localFetch(`${this.address}/api/function`, { + ...this.fetchOptions, + body, + method: "POST", + headers + }); + if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) { + throw new Error(await response.text()); + } + const respJSON = await response.json(); + if (this.debug) { + for (const line of respJSON.logLines ?? []) { + logForFunction(this.logger, "info", "any", name, line); + } + } + switch (respJSON.status) { + case "success": + return jsonToConvex(respJSON.value); + case "error": + if (respJSON.errorData !== void 0) { + throw forwardErrorData( + respJSON.errorData, + new ConvexError(respJSON.errorMessage) + ); + } + throw new Error(respJSON.errorMessage); + default: + throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`); + } + } + }; + function forwardErrorData(errorData, error) { + error.data = jsonToConvex(errorData); + return error; + } + + // src/browser/query_options.ts + function convexQueryOptions(options) { + return options; + } + return __toCommonJS(browser_bundle_exports); +})(); +//# sourceMappingURL=browser.bundle.js.map diff --git a/web/index.html b/web/index.html index 31f272a1..40f59cea 100644 --- a/web/index.html +++ b/web/index.html @@ -34,6 +34,12 @@ + +